Complete XYZAC LinuxCNC WASM parity and smooth 3D preview
This commit is contained in:
@@ -9,6 +9,7 @@ export const AXIS_COMMANDS = Object.freeze({
|
||||
run: "PROGRAM_RUN", step: "PROGRAM_STEP", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME",
|
||||
stop: "PROGRAM_ABORT", restart: "PROGRAM_RESTART", mdi: "MDI_EXECUTE", jogStart: "JOG_START",
|
||||
jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER",
|
||||
power: "POWER",
|
||||
spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE",
|
||||
toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG",
|
||||
reset: "RESET",
|
||||
@@ -99,3 +100,27 @@ export function axisGate(action, input = {}) {
|
||||
export function axisGateMatrix(input = {}) {
|
||||
return Object.freeze(Object.fromEntries(Object.keys(AXIS_COMMANDS).map((action) => [action, axisGate(action, input)])));
|
||||
}
|
||||
|
||||
export const XYZAC_GATE_ACTIONS = Object.freeze(["switchkinsIdentity", "switchkinsXyzac", "switchkinsUserk", "vismachClear"]);
|
||||
export const XYZAC_COMMANDS = Object.freeze({ switchkinsIdentity: "KINEMATICS_SELECT", switchkinsXyzac: "KINEMATICS_SELECT", switchkinsUserk: "KINEMATICS_SELECT", vismachClear: "VISUAL_CLEAR" });
|
||||
|
||||
export function xyzacGate(action, input = {}) {
|
||||
const state = normalizeAxisState(input);
|
||||
const xyzac = input.xyzac ?? {};
|
||||
const idle = state.interpState === "IDLE" && state.taskState === "ON";
|
||||
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;
|
||||
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";
|
||||
if (action === "switchkinsIdentity") return Object.freeze({ action, enabled: common, reason: common ? "xyzac_m429_available" : commonReason, required_type: 0, taskState: state.taskState, interpState: state.interpState });
|
||||
if (action === "switchkinsXyzac") return Object.freeze({ action, enabled: common && coordinates, reason: !common ? commonReason : !coordinates ? "xyzac_coordinates_invalid" : "xyzac_m428_available", required_type: 1, taskState: state.taskState, interpState: state.interpState });
|
||||
if (action === "switchkinsUserk") return Object.freeze({ action, enabled: common && Boolean(xyzac.userk_capability), reason: !common ? commonReason : !xyzac.userk_capability ? "xyzac_userk_unavailable" : "xyzac_m430_available", required_type: 2, taskState: state.taskState, interpState: state.interpState });
|
||||
return Object.freeze({ action, enabled: false, reason: "xyzac_gate_unknown_action", taskState: state.taskState, interpState: state.interpState });
|
||||
}
|
||||
|
||||
export function xyzacGateMatrix(input = {}) {
|
||||
return Object.freeze(Object.fromEntries(XYZAC_GATE_ACTIONS.map((action) => [action, xyzacGate(action, input)])));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AXIS_COMMANDS, axisGate } from "./axis-contract.mjs";
|
||||
import { AXIS_COMMANDS, XYZAC_GATE_ACTIONS, axisGate, xyzacGate } from "./axis-contract.mjs";
|
||||
|
||||
export const COMMAND_ACTIONS = Object.freeze(Object.fromEntries(Object.entries(AXIS_COMMANDS).map(([action, command]) => [command, action])));
|
||||
export const COMMAND_ACTIONS = Object.freeze({ ...Object.fromEntries(Object.entries(AXIS_COMMANDS).map(([action, command]) => [command, action])), VISUAL_CLEAR: "vismachClear" });
|
||||
|
||||
export class AxisGateError extends Error {
|
||||
constructor(result) {
|
||||
@@ -26,17 +26,18 @@ export class AxisOperationController {
|
||||
|
||||
async execute(kind, payload = {}, { state = {}, action = COMMAND_ACTIONS[kind] ?? null, gate = true } = {}) {
|
||||
const operationId = this.nextOperationId(kind);
|
||||
const gateResult = action ? axisGate(action, state) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
|
||||
const gateResult = action ? (XYZAC_GATE_ACTIONS.includes(action) ? xyzacGate(action, state) : axisGate(action, state)) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
|
||||
const authoritativeGate = action && state?.gates?.[action];
|
||||
if (gate && !gateResult.enabled && !authoritativeGate) {
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: gateResult.reason }));
|
||||
throw new AxisGateError(gateResult);
|
||||
const effectiveGate = authoritativeGate ?? gateResult;
|
||||
if (gate && !effectiveGate.enabled) {
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: effectiveGate.reason }));
|
||||
throw new AxisGateError(effectiveGate);
|
||||
}
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "pending", reason: gateResult.reason }));
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "pending", reason: effectiveGate.reason }));
|
||||
try {
|
||||
const response = await this.client.send(kind, payload, operationId);
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "result", response_kind: response?.kind ?? null }));
|
||||
return Object.freeze({ operationId, response, gate: gateResult });
|
||||
return Object.freeze({ operationId, response, gate: effectiveGate });
|
||||
} catch (error) {
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
|
||||
throw error;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { INITIAL_STATE, reduceFrontendState } from "./store";
|
||||
import { ReactWorkerClient } from "./worker-client";
|
||||
import { ThreeMachineView } from "./ThreeMachineView";
|
||||
import { XyzacToolPreviewFromSnapshot } from "./XyzacToolPreview";
|
||||
import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { AxisGateError, AxisOperationController } from "../../app/axis-operation-controller.mjs";
|
||||
import { axisGate } from "../../app/axis-contract.mjs";
|
||||
import { XyzacDiagnostics, XyzacProgramPane, XyzacTrtPanel } from "./XyzacTrtPanel";
|
||||
import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract";
|
||||
|
||||
export const SELECTED_AXIS3_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const DEFAULT_CASE = SELECTED_AXIS3_CASE;
|
||||
const PROGRAM_PATH = "linuxcnc/configs/nc_files/3dtest.ngc";
|
||||
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";
|
||||
const DEFAULT_PROGRAM = "";
|
||||
|
||||
type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void;
|
||||
@@ -38,6 +40,20 @@ function AxisRadio({ axis, selected, onChange }: { axis: string; selected: strin
|
||||
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
|
||||
}
|
||||
|
||||
function MachineSequence({ isEstop, machineOn, allHomed, programLoaded, programRunning, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) {
|
||||
return <section className="machine-sequence" data-axis-feature="AXIS-MACHINE-SEQUENCE" aria-label="Machine sequence">
|
||||
<div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{isEstop ? "ESTOP" : machineOn ? "ON" : "OFF"} / {allHomed ? "HOME ALL" : "NOT HOMED"}</span></div>
|
||||
<div className="machine-sequence-actions" role="group" aria-label="Emergency stop, power, homing and program controls">
|
||||
<TextButton danger disabled={isEstop} onClick={onEstop} ariaLabel="Emergency stop">Emergency Stop</TextButton>
|
||||
<TextButton disabled={!isEstop} onClick={onRelease} ariaLabel="Release emergency stop">Release E-stop</TextButton>
|
||||
<TextButton disabled={isEstop} onClick={onPower} ariaLabel="Power on">Power On</TextButton>
|
||||
<TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel="Home all axes">HOME ALL</TextButton>
|
||||
<TextButton disabled={!canRun} onClick={onRun} ariaLabel="Run program">Run Program</TextButton>
|
||||
<TextButton feature="AXIS-MACHINE-SEQUENCE" disabled={!programLoaded || programRunning} onClick={onSequence} ariaLabel="Run startup sequence">Run Startup Sequence</TextButton>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ManualControl({ action, ready, canHome, onHomeAll, selectedAxis, setSelectedAxis, spindleOn, coolant, onSpindle, onCoolant, devices }: { action: Action; ready: boolean; canHome: boolean; onHomeAll: () => void; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; devices: Record<string, any> }) {
|
||||
const [increment, setIncrement] = useState("Continuous");
|
||||
const [jogSpeed, setJogSpeed] = useState(50);
|
||||
@@ -48,7 +64,7 @@ function ManualControl({ action, ready, canHome, onHomeAll, selectedAxis, setSel
|
||||
const jog = (direction: number) => { setJogging(true); const request = action("JOG_START", { axis: selectedAxis, direction, speed: increment === "Continuous" ? 1 : 1, increment: increment === "Continuous" ? null : Number(increment) }); if (increment !== "Continuous" && request && typeof (request as Promise<void>).then === "function") void (request as Promise<void>).then(() => action("SIM_STEP", { cycles: 1 })); };
|
||||
const stopJog = () => { if (!jogging) return; setJogging(false); void action("JOG_STOP"); };
|
||||
return <section className="manual-control" data-axis-feature="AXIS-MANUAL-01" aria-label="Manual Control"><div className="manual-grid">
|
||||
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
|
||||
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "c"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
|
||||
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!ready} onPointerDown={() => jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>-</TextButton><TextButton disabled={!ready} onPointerDown={() => jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!ready} onClick={stopJog}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!canHome} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</TextButton><TextButton disabled={!canHome} onClick={onHomeAll}>Home All</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" checked={overrideLimits} onChange={(event) => setOverrideLimits(event.target.checked)} /> Override Limits</label></div>
|
||||
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!ready} pressed={spindleOn === false} onClick={() => onSpindle(-1)} /><TextButton disabled={!ready} onClick={() => onSpindle(0)}>Stop</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!ready} pressed={spindleOn} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>-</TextButton><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+</TextButton><label className="check-line"><input type="checkbox" checked={brake} onChange={(event) => setBrake(event.target.checked)} /> Brake</label></div></div>
|
||||
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} onChange={() => onCoolant("flood")} /> Flood</label></div>
|
||||
@@ -63,13 +79,13 @@ function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
|
||||
}
|
||||
|
||||
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", "B", "C", "U", "V", "W"];
|
||||
const labels = ["X", "Y", "Z", "A", "C"];
|
||||
const scale = metric ? 1 : 1 / 25.4;
|
||||
const format = (value: number) => (value * scale).toFixed(4);
|
||||
const format = (value: number, index: number) => (value * (index < 3 ? scale : 1)).toFixed(4);
|
||||
const pose = (state.snapshot?.visual?.pose ?? state.snapshot?.authoritative?.kinematics?.pose) as Record<string, number> | undefined;
|
||||
const commanded = [pose?.x, pose?.y, pose?.z].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0));
|
||||
const relative = state.snapshot?.coordinates?.relative ?? [0, 0, 0];
|
||||
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const actual = Number(axes[index] ?? 0); const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed)}</output><output>{format(actual)}</output><output>{format(relativeValue)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b></span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
|
||||
const commanded = [pose?.x, pose?.y, pose?.z, pose?.a, pose?.c].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0));
|
||||
const relative = state.snapshot?.coordinates?.relative ?? [0, 0, 0, 0, 0];
|
||||
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = Number(axes[index] ?? 0); const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
|
||||
}
|
||||
|
||||
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, onOpenFile, canLoad, canRun, canStep, currentLine, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; onOpenFile: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; diagnostic: string | null }) {
|
||||
@@ -94,7 +110,7 @@ export function App() {
|
||||
const client = useMemo(() => new ReactWorkerClient(), []);
|
||||
const operationController = useMemo(() => new AxisOperationController(client), [client]);
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const [casePath, setCasePath] = useState(DEFAULT_CASE);
|
||||
const [casePath, setCasePath] = useState<string>(DEFAULT_CASE);
|
||||
const [caseStatus, setCaseStatus] = useState("ready to load");
|
||||
const [commandStatus, setCommandStatus] = useState("Idle");
|
||||
const [programText, setProgramText] = useState(DEFAULT_PROGRAM);
|
||||
@@ -112,12 +128,16 @@ export function App() {
|
||||
const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false });
|
||||
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const runStepPending = useRef(false);
|
||||
const jogStepPending = useRef(false);
|
||||
const programFileInput = useRef<HTMLInputElement>(null);
|
||||
const [plotCleared, setPlotCleared] = useState(false);
|
||||
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("tutorial_program_missing"); setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source, path: PROGRAM_PATH }, { state: {}, gate: false }); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); client.close(); }; }, [client, operationController]);
|
||||
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
|
||||
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { 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]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); client.close(); }; }, [client, operationController]);
|
||||
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
|
||||
const taskRecord = state.task ?? {};
|
||||
const axisState = { task: { ...taskRecord, state: taskRecord.state ?? "OFF", mode: taskRecord.mode ?? "MANUAL", interp_state: taskRecord.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, gates: state.gates };
|
||||
const axisState = { task: { ...taskRecord, state: taskRecord.state ?? "OFF", mode: taskRecord.mode ?? "MANUAL", interp_state: taskRecord.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates };
|
||||
const xyzacView = useMemo(() => projectXyzacPanel(state.snapshot), [state.snapshot]);
|
||||
const machineState = state.axisState?.machine_state ?? (String(taskRecord.state ?? "OFF").toUpperCase() === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(String(taskRecord.state ?? "").toUpperCase()) ? "ON" : "OFF");
|
||||
const operationState = state.axisState?.operation_state ?? (state.status === "running" ? "RUNNING" : state.status === "paused" ? "PAUSED" : "IDLE");
|
||||
const taskState = state.axisState?.state ?? (machineState === "ESTOP" ? "ESTOP" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "ON" ? "IDLE" : "OFF");
|
||||
@@ -133,11 +153,14 @@ export function App() {
|
||||
const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])];
|
||||
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
|
||||
const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
|
||||
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined); }, 50); };
|
||||
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined); }, 50); };
|
||||
const dispatch = (kind: string, payload: Record<string, unknown> = {}, gate = true): Promise<void> => {
|
||||
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); };
|
||||
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); };
|
||||
const dispatch = (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => {
|
||||
setCommandStatus(`${kind} pending`);
|
||||
return operationController.execute(kind, payload, { state: axisState, gate }).then(() => { setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN") startRunTimer(); if (kind === "PROGRAM_ABORT" || kind === "PROGRAM_RESTART") stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
|
||||
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
|
||||
setOperationTrace(operationController.exportTrace());
|
||||
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN") startRunTimer(); if (kind === "PROGRAM_ABORT" || kind === "PROGRAM_RESTART") stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
|
||||
setOperationTrace(operationController.exportTrace());
|
||||
const code = error instanceof AxisGateError ? error.code : error.message;
|
||||
setCommandStatus(`${kind} blocked: ${code}`);
|
||||
throw error;
|
||||
@@ -146,11 +169,20 @@ export function App() {
|
||||
const execute: Action = (kind, payload = {}) => dispatch(kind, payload).catch(() => undefined);
|
||||
const runProgram = () => { void dispatch("PROGRAM_RUN").catch(() => undefined); };
|
||||
const stopProgram = () => { const wasRunning = Boolean(runTimer.current) || gates.abort.enabled; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, gates.abort.enabled).catch(() => undefined); };
|
||||
const toggleEstop = () => { if (!isEstop) { void dispatch("ESTOP", { active: true }).catch(() => undefined); return; } void dispatch("ESTOP", { active: false }).then(() => dispatch("IO_RECOVER")).catch(() => undefined); };
|
||||
const powerOn = () => { const recover = () => dispatch("IO_RECOVER"); if (isEstop) void dispatch("ESTOP", { active: false }).then(recover).catch(() => undefined); else void recover().catch(() => undefined); };
|
||||
const emergencyStop = () => { stopRunTimer(); stopJogTimer(); void dispatch("ESTOP", { active: true }, false).catch(() => undefined); };
|
||||
const releaseEstop = () => dispatch("ESTOP", { active: false }, false).then(() => dispatch("IO_RECOVER", {}, false));
|
||||
const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); };
|
||||
const powerOn = () => { const power = () => dispatch("POWER", { active: true }, false); if (isEstop) void releaseEstop().then(power).catch(() => undefined); else void power().catch(() => undefined); };
|
||||
const resetMachine = () => { stopRunTimer(); stopJogTimer(); const reset = () => dispatch("RESET", {}, false); if (programRunning) void dispatch("PROGRAM_ABORT").then(() => isEstop ? dispatch("ESTOP", { active: false }) : Promise.resolve()).then(reset).catch(() => undefined); else if (isEstop) void dispatch("ESTOP", { active: false }).then(reset).catch(() => undefined); else void reset().catch(() => undefined); };
|
||||
const homeAll = () => { void dispatch("HOME", { axis: "z" }).then(() => dispatch("HOME", { axis: "x" })).then(() => dispatch("HOME", { axis: "y" })).catch(() => undefined); };
|
||||
const loadCase = async () => { setCaseStatus("loading session..."); try { await client.ensureSession(); setCaseStatus("loading exact case..."); const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } setCaseStatus("starting exact case..."); 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; const source = bundle.files?.[PROGRAM_PATH]; if (typeof source === "string" && source.trim()) { setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); setPlotCleared(false); await operationController.execute("PROGRAM_LOAD", { source, path: PROGRAM_PATH }, { state: axisState, gate: false }); setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> READY`); } else { setCaseStatus(`${bundle.entrypoint} -> ${resultPayload?.configuration?.state ?? "READY"} (no program)`); } } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
|
||||
const homeAll = () => { void dispatch("HOME", { all: true }).catch(() => undefined); };
|
||||
const runStartupSequence = () => {
|
||||
const release = isEstop ? releaseEstop() : Promise.resolve();
|
||||
void release.then(() => dispatch("POWER", { active: true }, false))
|
||||
.then(() => dispatch("HOME", { all: true }, false))
|
||||
.then(() => dispatch("PROGRAM_RUN", {}, false))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const loadCase = async () => { setCaseStatus("loading session..."); try { await client.ensureSession(); setCaseStatus("loading exact XYZAC case..."); const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); setCaseStatus("starting exact case..."); 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; setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); setPlotCleared(false); await operationController.execute("PROGRAM_LOAD", { source, path: PROGRAM_PATH }, { state: axisState, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setOperationTrace(operationController.exportTrace()); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
|
||||
const openProgramFile = () => programFileInput.current?.click();
|
||||
const onProgramFile = async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const source = await file.text(); setProgramText(source); setPlotCleared(false); setCommandStatus(`${file.name} loaded in editor; press Load to send it to LinuxCNC`); };
|
||||
const enterManualMode = () => { setLeftTab("manual"); if (String(axisState.task.mode).toUpperCase() !== "MANUAL") void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined); };
|
||||
@@ -159,7 +191,7 @@ export function App() {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.ctrlKey && key === "k") { event.preventDefault(); setCommandStatus("Live plot cleared"); return; }
|
||||
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").then(() => setPlotCleared(true)).catch(() => undefined); return; }
|
||||
if (key === "f3") { event.preventDefault(); enterManualMode(); return; }
|
||||
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; }
|
||||
if (key === "f1") { event.preventDefault(); toggleEstop(); return; }
|
||||
@@ -176,7 +208,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
const wasmQueueDepth = Number((state.snapshot?.wasm_motion as { state?: { queue_depth?: number } } | undefined)?.state?.queue_depth ?? 0);
|
||||
const programComplete = String(state.program?.program_status ?? "") === "completed" && wasmQueueDepth === 0;
|
||||
if (!programRunning || programComplete) stopRunTimer();
|
||||
if (programRunning && !programComplete) startRunTimer(); else stopRunTimer();
|
||||
if (!ready) { stopRunTimer(); stopJogTimer(); }
|
||||
}, [programRunning, ready, state.program?.program_status, state.snapshot?.wasm_motion]);
|
||||
const spindleOn = Boolean((state.devices as Record<string, any> | null)?.spindles?.[0]?.enabled);
|
||||
@@ -185,15 +217,21 @@ export function App() {
|
||||
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
|
||||
const activeGcodes = state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9";
|
||||
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
|
||||
const position = `X${Number(axes[0] ?? 0).toFixed(3)} Y${Number(axes[1] ?? 0).toFixed(3)} Z${Number(axes[2] ?? 0).toFixed(3)}`;
|
||||
const currentLine = Number(state.program?.line ?? state.task?.current_line ?? 0) || null;
|
||||
const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : [];
|
||||
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
|
||||
const xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); };
|
||||
const position = `X${Number(xyzacView.pose?.x ?? axes[0] ?? 0).toFixed(3)} Y${Number(xyzacView.pose?.y ?? axes[1] ?? 0).toFixed(3)} Z${Number(xyzacView.pose?.z ?? axes[2] ?? 0).toFixed(3)} A${Number(xyzacView.pose?.a ?? 0).toFixed(3)} C${Number(xyzacView.pose?.c ?? 0).toFixed(3)}`;
|
||||
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
||||
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
|
||||
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={openProgramFile}>Open G-code...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn}>Power on</MenuAction><MenuAction onClick={resetMachine}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label="Power on / recover [F2]" asset="tool_power.gif" onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact 3axis tutorial case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => { setPlotCleared(true); setCommandStatus("Live plot cleared"); }} /></section>
|
||||
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_AXIS3_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | 3-axis AXIS</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
|
||||
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><ThreeMachineView axes={axes} segments={plotCleared ? [] : (state.toolpath.length ? state.toolpath : trajectory.map((segment) => ({ ...segment, active: true })))} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label="Power on / recover [F2]" asset="tool_power.gif" onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
|
||||
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
|
||||
<MachineSequence isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
|
||||
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
|
||||
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
|
||||
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
|
||||
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={Number(state.program?.line ?? state.task?.current_line ?? 0) || null} diagnostic={state.error?.code ?? null} />
|
||||
<XyzacProgramPane status={String(state.program?.program_status ?? "idle")} currentLine={currentLine} sourceHash={String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: PROGRAM_PATH }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={currentLine} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
|
||||
<input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
|
||||
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
|
||||
</main>;
|
||||
|
||||
115
web/react/src/XyzacMachineView.tsx
Normal file
115
web/react/src/XyzacMachineView.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
|
||||
|
||||
type Point = Record<string, number>;
|
||||
type Segment = { id?: number; active?: boolean; motion_type?: number; turn?: number; center?: Point; normal?: Point; samples?: Point[]; start?: Point; end?: Point };
|
||||
type Props = { visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const MM = 0.01;
|
||||
|
||||
function material(color: number) { return new THREE.MeshStandardMaterial({ color, roughness: .78, metalness: .12 }); }
|
||||
function sourcePosition(x: number, y: number, z: number) { return new THREE.Vector3(x * MM, z * MM, y * MM); }
|
||||
function sourceBox(width: number, depth: number, height: number, color: number) { return new THREE.Mesh(new THREE.BoxGeometry(width * MM, height * MM, depth * MM), material(color)); }
|
||||
function sourceCylinderZ(radius: number, height: number, color: number) { return new THREE.Mesh(new THREE.CylinderGeometry(radius * MM, radius * MM, height * MM, 32), material(color)); }
|
||||
function translate(name: string, object: THREE.Object3D, x: number, y: number, z: number) { object.name = name; object.position.copy(sourcePosition(x, y, z)); return object; }
|
||||
|
||||
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const visualRef = useRef(visual); visualRef.current = visual;
|
||||
const toolpathRef = useRef(toolpath); toolpathRef.current = toolpath;
|
||||
const viewRef = useRef(view); viewRef.current = view;
|
||||
const zoomRef = useRef(zoom); zoomRef.current = zoom;
|
||||
const rotateRef = useRef(rotateMode); rotateRef.current = rotateMode;
|
||||
const layersRef = useRef({ showPath, showLive, showLimits, showVelocity, showOffsets, showExtents });
|
||||
layersRef.current = { showPath, showLive, showLimits, showVelocity, showOffsets, showExtents };
|
||||
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x081017);
|
||||
scene.add(new THREE.HemisphereLight(0xe6f2ff, 0x202830, 1.65));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 8, 10); scene.add(key);
|
||||
const camera = new THREE.PerspectiveCamera(38, 1, .1, 100);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||
element.appendChild(renderer.domElement);
|
||||
|
||||
const machine = new THREE.Group(); machine.name = "xyzac-machine"; scene.add(machine);
|
||||
machine.add(translate("base", sourceBox(240, 260, 150, 0x43616b), 0, 30, -175));
|
||||
machine.add(translate("column", sourceBox(100, 100, 510, 0x3b7a72), 0, 150, 5));
|
||||
const head = new THREE.Group(); head.name = "head"; head.position.copy(sourcePosition(0, 0, 200)); machine.add(head);
|
||||
head.add(translate("head-body", sourceBox(60, 60, 135, 0x277c83), 0, 0, 87.5));
|
||||
head.add(translate("motor", sourceCylinderZ(30, 65, 0x277c83), 0, 200, 165));
|
||||
const spindle = new THREE.Group(); spindle.name = "spindle"; spindle.position.copy(sourcePosition(0, 0, 20)); head.add(spindle);
|
||||
spindle.add(translate("spindle-housing", sourceCylinderZ(20, 135, 0x31888d), 0, 0, 67.5));
|
||||
spindle.add(translate("spindle-nose", sourceCylinderZ(10, 20, 0x4da0a0), 0, 0, 10));
|
||||
const tool = new THREE.Group(); tool.name = "tool-offset"; spindle.add(tool);
|
||||
tool.add(translate("tool-holder", sourceCylinderZ(6, 70, 0x9da7a8), 0, 0, 35));
|
||||
tool.add(translate("tool", sourceCylinderZ(3, 70, 0xd85f4a), 0, 0, 105));
|
||||
|
||||
const zslide = new THREE.Group(); zslide.name = "spindle-z"; machine.add(zslide);
|
||||
const saddle = new THREE.Group(); saddle.name = "saddle-y"; zslide.add(saddle);
|
||||
saddle.add(translate("saddle-body", sourceBox(150, 106, 32, 0x8b9697), 0, 0, -89));
|
||||
saddle.add(translate("z-slide", sourceBox(100, 220, 75, 0xb8bdbe), 0, 10, -142.5));
|
||||
const tableX = new THREE.Group(); tableX.name = "table-x"; saddle.add(tableX);
|
||||
tableX.add(translate("table-bed", sourceBox(300, 100, 17, 0x686f72), 0, 0, -60.5));
|
||||
tableX.add(translate("ways", sourceBox(300, 80, 6, 0x50575a), 0, 0, -72));
|
||||
const tilt = new THREE.Group(); tilt.name = "tilt-a"; tableX.add(tilt);
|
||||
const yOffset = 20; const zOffset = 10;
|
||||
tilt.position.copy(sourcePosition(0, yOffset, zOffset));
|
||||
tilt.rotation.x = 0;
|
||||
const rotate = new THREE.Group(); rotate.name = "rotate-c"; tilt.add(rotate);
|
||||
rotate.position.copy(sourcePosition(0, -yOffset, -zOffset));
|
||||
rotate.add(translate("trunnion", sourceBox(145, 80, 35, 0xb17540), 0, 0, -30));
|
||||
rotate.add(translate("table", sourceCylinderZ(50, 18, 0xb17540), 0, 0, -9));
|
||||
rotate.rotation.y = 0;
|
||||
const yoke = new THREE.Group(); yoke.name = "yoke"; tilt.add(yoke);
|
||||
yoke.add(translate("yoke-plate", sourceBox(130, 80, 10, 0xb17540), 0, 0, -30));
|
||||
const brackets = new THREE.Group(); brackets.name = "brackets"; tableX.add(brackets);
|
||||
brackets.position.copy(sourcePosition(0, yOffset, zOffset));
|
||||
brackets.add(translate("bracket-left", sourceBox(10, 80, 50, 0x9b6b3b), -72, 0, -25));
|
||||
brackets.add(translate("bracket-right", sourceBox(10, 80, 50, 0x9b6b3b), 72, 0, -25));
|
||||
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; scene.add(pathGroup);
|
||||
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; scene.add(liveGroup);
|
||||
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-overlays"; scene.add(overlayGroup);
|
||||
const resize = () => { const width = Math.max(element.clientWidth, 280); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
|
||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||
|
||||
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM);
|
||||
let frame = 0;
|
||||
const render = () => {
|
||||
const state = visualRef.current; const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints;
|
||||
tableX.position.x = -tableXJoint * MM; saddle.position.z = -saddleYJoint * MM; zslide.position.y = (200 - spindleZJoint) * MM;
|
||||
tilt.rotation.x = -tiltAJoint * DEG; rotate.rotation.y = -rotateCJoint * DEG; tool.position.y = -state.offsets.tool_offset * MM;
|
||||
pathGroup.clear(); liveGroup.clear();
|
||||
const completed = new THREE.LineBasicMaterial({ color: 0xdce7e8 }); const active = new THREE.LineBasicMaterial({ color: 0xff9a55 }); const rapid = new THREE.LineBasicMaterial({ color: 0x5c9d9a });
|
||||
for (const raw of (toolpathRef.current && toolpathRef.current.length > 0 ? toolpathRef.current : state.toolpath) as Segment[]) {
|
||||
const start = raw.start ?? {}; const end = raw.end ?? {};
|
||||
const points = Array.isArray(raw.samples) && raw.samples.length > 1 ? raw.samples.map(toView) : [toView(start), toView(end)];
|
||||
const group = raw.active || raw.id === state.activeSegmentId ? liveGroup : pathGroup; const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), raw.active || raw.id === state.activeSegmentId ? active : raw.motion_type === 0 ? rapid : completed); group.add(line);
|
||||
}
|
||||
const layer = layersRef.current;
|
||||
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
|
||||
overlayGroup.clear();
|
||||
if (layer.showLimits) { const limits = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(4, 2, 3)), new THREE.LineBasicMaterial({ color: 0xdd7566 })); limits.position.set(0, .8, 0); overlayGroup.add(limits); }
|
||||
if (layer.showOffsets) { const offset = new THREE.AxesHelper(1.8); offset.name = "xyzac-offset-axes"; offset.position.set(state.offsets.x_rot_point * MM, state.offsets.z_offset * MM, state.offsets.y_offset * MM); overlayGroup.add(offset); }
|
||||
overlayGroup.visible = layer.showLimits || layer.showOffsets || layer.showExtents || layer.showVelocity;
|
||||
const distance = 9 / Math.max(.5, zoomRef.current); const selected = viewRef.current;
|
||||
if (selected === "z") camera.position.set(0, distance, .01); else if (selected === "z2") camera.position.set(distance * .3, distance * .9, distance * .3); else if (selected === "x") camera.position.set(distance, 2, .01); else if (selected === "y") camera.position.set(.01, 2, distance); else if (selected === "y2") camera.position.set(.01, 2, -distance); else camera.position.set(6 / Math.max(.5, zoomRef.current), 5 / Math.max(.5, zoomRef.current), 7 / Math.max(.5, zoomRef.current));
|
||||
camera.lookAt(0, 0, 0); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
return () => { cancelAnimationFrame(frame); observer.disconnect(); renderer.dispose(); element.replaceChildren(); };
|
||||
}, []);
|
||||
|
||||
const pose = visual.pose;
|
||||
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label="XYZAC TRT Vismach machine"><pre className="axis-preview-hud" aria-label="XYZAC authoritative visual state">{`XYZAC TRT\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)}\nJ ${visual.joints.map((value) => Number(value).toFixed(2)).join(" ")}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}`}</pre></div>;
|
||||
}
|
||||
|
||||
export function XyzacMachineViewFromSnapshot({ snapshot, toolpath, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
|
||||
return <XyzacMachineView visual={projectXyzacVisual(snapshot)} toolpath={toolpath} {...props} />;
|
||||
}
|
||||
258
web/react/src/XyzacToolPreview.tsx
Normal file
258
web/react/src/XyzacToolPreview.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
|
||||
|
||||
type Point = Record<string, number>;
|
||||
type Segment = { id?: number; active?: boolean; kind?: string; motion_type?: number; kinematics_mode?: number; turn?: number; center?: Point; normal?: Point; samples?: Point[]; start?: Point; end?: Point };
|
||||
type Props = { visual: XyzacVisualState; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const MM = 0.01;
|
||||
const sourcePoint = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM);
|
||||
|
||||
export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const hudRef = useRef<HTMLPreElement>(null);
|
||||
const visualRef = useRef(visual); visualRef.current = visual;
|
||||
const viewRef = useRef(view); viewRef.current = view;
|
||||
const zoomRef = useRef(zoom); zoomRef.current = zoom;
|
||||
const rotateRef = useRef(rotateMode); rotateRef.current = rotateMode;
|
||||
const layersRef = useRef({ showPath, showLive, showLimits, showVelocity, showOffsets, showExtents });
|
||||
layersRef.current = { showPath, showLive, showLimits, showVelocity, showOffsets, showExtents };
|
||||
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050a0e);
|
||||
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
|
||||
element.appendChild(renderer.domElement);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = .12;
|
||||
controls.enableZoom = true;
|
||||
controls.enablePan = true;
|
||||
controls.enableRotate = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.target.set(0, 0, 0);
|
||||
const originGroup = new THREE.Group(); originGroup.name = "xyzac-origin-coordinate-system"; scene.add(originGroup);
|
||||
const origin = new THREE.Mesh(new THREE.SphereGeometry(.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0xffffff })); origin.name = "xyzac-origin"; originGroup.add(origin);
|
||||
const originAxes = [
|
||||
{ label: "X", direction: new THREE.Vector3(1, 0, 0), color: 0xe5534b, position: new THREE.Vector3(.72, 0, 0) },
|
||||
{ label: "Y", direction: new THREE.Vector3(0, 0, 1), color: 0x55b86a, position: new THREE.Vector3(0, 0, .72) },
|
||||
{ label: "Z", direction: new THREE.Vector3(0, 1, 0), color: 0x4d8fe8, position: new THREE.Vector3(0, .72, 0) },
|
||||
];
|
||||
for (const axis of originAxes) {
|
||||
const arrow = new THREE.ArrowHelper(axis.direction, new THREE.Vector3(), .62, axis.color, .1, .055); arrow.name = `xyzac-origin-axis-${axis.label.toLowerCase()}`; originGroup.add(arrow);
|
||||
const labelCanvas = document.createElement("canvas"); labelCanvas.width = 96; labelCanvas.height = 48;
|
||||
const labelContext = labelCanvas.getContext("2d");
|
||||
if (labelContext) { labelContext.clearRect(0, 0, labelCanvas.width, labelCanvas.height); labelContext.fillStyle = `#${axis.color.toString(16).padStart(6, "0")}`; labelContext.font = "bold 32px sans-serif"; labelContext.textAlign = "center"; labelContext.textBaseline = "middle"; labelContext.fillText(axis.label, 48, 24); }
|
||||
const label = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(labelCanvas), transparent: true, depthTest: false })); label.name = `xyzac-origin-label-${axis.label.toLowerCase()}`; label.position.copy(axis.position); label.scale.set(.42, .21, 1); originGroup.add(label);
|
||||
}
|
||||
const grid = new THREE.GridHelper(2.5, 10, 0x304553, 0x18252d); grid.name = "xyzac-preview-grid"; scene.add(grid);
|
||||
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-preview-toolpath"; scene.add(pathGroup);
|
||||
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-preview-live-toolpath"; scene.add(liveGroup);
|
||||
const toolGroup = new THREE.Group(); toolGroup.name = "xyzac-preview-tool"; scene.add(toolGroup);
|
||||
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-preview-overlays"; scene.add(overlayGroup);
|
||||
const offsetAxes = new THREE.AxesHelper(1.5); offsetAxes.name = "xyzac-preview-offset-axes"; overlayGroup.add(offsetAxes);
|
||||
const limitGrid = new THREE.GridHelper(2.5, 10, 0x8b4d47, 0x4f2d2d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
|
||||
const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: .45, metalness: .35 });
|
||||
const tool = new THREE.Mesh(new THREE.CylinderGeometry(.04, .04, .58, 20), toolMaterial); tool.name = "xyzac-preview-tool-shaft"; toolGroup.add(tool);
|
||||
const tipGeometry = new THREE.ConeGeometry(.09, .28, 24); tipGeometry.rotateZ(Math.PI); tipGeometry.translate(0, .14, 0);
|
||||
const tip = new THREE.Mesh(tipGeometry, new THREE.MeshBasicMaterial({ color: 0xf2f2f2 })); tip.name = "xyzac-preview-tool-tip"; toolGroup.add(tip);
|
||||
const resize = () => { const width = Math.max(element.clientWidth, 280); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
|
||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||
const fit = (segments: Segment[]) => {
|
||||
const points = segments.flatMap((segment) => (Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples : [segment.start ?? {}, segment.end ?? {}]).map(sourcePoint));
|
||||
if (!points.length) return { center: new THREE.Vector3(), radius: .12 };
|
||||
const box = new THREE.Box3().setFromPoints(points);
|
||||
return { center: box.getCenter(new THREE.Vector3()), radius: Math.max(box.getSize(new THREE.Vector3()).length() * .65, .12) };
|
||||
};
|
||||
const clearLines = (group: THREE.Group) => {
|
||||
for (const child of group.children) {
|
||||
const line = child as THREE.Line;
|
||||
line.geometry?.dispose();
|
||||
}
|
||||
group.clear();
|
||||
};
|
||||
const completed = new THREE.LineBasicMaterial({ color: 0xd8dfe3, depthTest: false });
|
||||
const rapid = new THREE.LineDashedMaterial({ color: 0xa85d5d, dashSize: .025, gapSize: .018, depthTest: false });
|
||||
const live = new THREE.LineBasicMaterial({ color: 0xffd166, depthTest: false });
|
||||
const liveHistory = new THREE.LineBasicMaterial({ color: 0xff6b4a, depthTest: false });
|
||||
let frame = 0;
|
||||
let liveTick = -1;
|
||||
let liveTrace: THREE.Vector3[] = [];
|
||||
let pathKey = "";
|
||||
let liveKey = "";
|
||||
let bounds = { center: new THREE.Vector3(), radius: .12 };
|
||||
let playbackQueue: Array<{ tick: number; pose: Point; joints: number[]; activeSegmentId: number; activeProgress: number }> = [];
|
||||
let playbackStart = 0;
|
||||
let queuedLastTick = -1;
|
||||
let renderedTick = -1;
|
||||
let renderedPose: Point = { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 };
|
||||
let renderedJoints: number[] = [];
|
||||
let renderedActiveSegment = 0;
|
||||
let renderedProgress = 0;
|
||||
let lastPreset = "";
|
||||
const presetCamera = (selected: Props["view"], selectedZoom: number) => {
|
||||
const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom);
|
||||
controls.target.copy(bounds.center);
|
||||
if (selected === "z") camera.position.copy(bounds.center).add(new THREE.Vector3(0, distance, .01));
|
||||
else if (selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .3, distance * .9, distance * .3));
|
||||
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(distance, .02, .01));
|
||||
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, distance));
|
||||
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, -distance));
|
||||
else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance));
|
||||
camera.lookAt(bounds.center);
|
||||
controls.update();
|
||||
};
|
||||
const pan = (horizontal: number, vertical: number) => {
|
||||
const amount = Math.max(.02, bounds.radius * .12);
|
||||
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
|
||||
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
|
||||
const offset = right.multiplyScalar(horizontal * amount).add(up.multiplyScalar(vertical * amount));
|
||||
camera.position.add(offset); controls.target.add(offset); controls.update();
|
||||
};
|
||||
const zoomCamera = (factor: number) => {
|
||||
const offset = new THREE.Vector3().subVectors(camera.position, controls.target);
|
||||
offset.multiplyScalar(factor);
|
||||
camera.position.copy(controls.target).add(offset); controls.update();
|
||||
};
|
||||
(element as typeof element & { xyzacController?: Record<string, () => void> }).xyzacController = {
|
||||
zoomIn: () => zoomCamera(.8), zoomOut: () => zoomCamera(1.25), panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1), reset: () => { lastPreset = ""; presetCamera(viewRef.current, zoomRef.current); },
|
||||
};
|
||||
const render = () => {
|
||||
const state = visualRef.current;
|
||||
const segments = state.toolpath as Segment[];
|
||||
const layer = layersRef.current;
|
||||
const now = performance.now();
|
||||
if (state.tick < queuedLastTick) {
|
||||
playbackQueue = [];
|
||||
playbackStart = 0;
|
||||
queuedLastTick = -1;
|
||||
}
|
||||
if (!state.motionTrace.length && state.tick > queuedLastTick) {
|
||||
playbackQueue = [];
|
||||
playbackStart = 0;
|
||||
queuedLastTick = state.tick;
|
||||
renderedTick = state.tick;
|
||||
renderedPose = state.livePose;
|
||||
renderedJoints = state.joints;
|
||||
renderedActiveSegment = state.activeSegmentId ?? 0;
|
||||
renderedProgress = state.activeProgress;
|
||||
}
|
||||
for (const sample of state.motionTrace) {
|
||||
if (sample.tick > queuedLastTick) {
|
||||
playbackQueue.push(sample);
|
||||
queuedLastTick = sample.tick;
|
||||
}
|
||||
}
|
||||
if (playbackQueue.length && playbackStart === 0) playbackStart = now;
|
||||
if (playbackQueue.length) {
|
||||
const index = Math.min(playbackQueue.length - 1, Math.max(0, Math.floor((now - playbackStart) / 10)));
|
||||
const sample = playbackQueue[index];
|
||||
renderedTick = sample.tick;
|
||||
renderedPose = sample.pose;
|
||||
renderedJoints = sample.joints;
|
||||
renderedActiveSegment = sample.activeSegmentId;
|
||||
renderedProgress = sample.activeProgress;
|
||||
if (index > 32) {
|
||||
playbackQueue.splice(0, index);
|
||||
playbackStart += index * 10;
|
||||
}
|
||||
} else {
|
||||
renderedTick = state.tick;
|
||||
renderedPose = state.livePose;
|
||||
renderedJoints = state.joints;
|
||||
renderedActiveSegment = state.activeSegmentId ?? 0;
|
||||
renderedProgress = state.activeProgress;
|
||||
}
|
||||
// The planned path is immutable for a loaded program. Rebuilding all
|
||||
// geometries on every animation frame starves worker snapshots and
|
||||
// makes an otherwise continuous WASM trajectory appear to stutter.
|
||||
const nextPathKey = segments.map((segment) => `${segment.id}:${segment.kind ?? ""}:${segment.samples?.length ?? 0}:${segment.start?.x ?? 0},${segment.start?.y ?? 0},${segment.start?.z ?? 0}:${segment.end?.x ?? 0},${segment.end?.y ?? 0},${segment.end?.z ?? 0}`).join("|");
|
||||
if (nextPathKey !== pathKey) {
|
||||
clearLines(pathGroup);
|
||||
bounds = fit(segments);
|
||||
for (const segment of segments) {
|
||||
const start = segment.start ?? {}; const end = segment.end ?? {};
|
||||
const points = Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
|
||||
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), segment.kind === "rapid" ? rapid : completed);
|
||||
if (segment.kind === "rapid") line.computeLineDistances();
|
||||
line.renderOrder = 10;
|
||||
pathGroup.add(line);
|
||||
}
|
||||
pathKey = nextPathKey;
|
||||
liveKey = "";
|
||||
}
|
||||
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
|
||||
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
|
||||
if (renderedTick < liveTick) liveTrace = [];
|
||||
if (renderedTick !== liveTick) {
|
||||
const current = sourcePoint(renderedPose);
|
||||
const previous = liveTrace.at(-1);
|
||||
if (!previous || previous.distanceTo(current) > 1e-9) liveTrace.push(current);
|
||||
liveTick = renderedTick;
|
||||
}
|
||||
element.dataset.xyzacLiveTraceCount = String(liveTrace.length);
|
||||
const nextLiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}:${liveTrace.length}`;
|
||||
if (nextLiveKey !== liveKey) {
|
||||
clearLines(liveGroup);
|
||||
if (liveTrace.length > 1) {
|
||||
const historyLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(liveTrace), liveHistory);
|
||||
historyLine.renderOrder = 19;
|
||||
liveGroup.add(historyLine);
|
||||
}
|
||||
const activeSegment = segments.find((segment) => Boolean(segment.active || segment.id === renderedActiveSegment));
|
||||
if (activeSegment) {
|
||||
const start = activeSegment.start ?? {}; const end = activeSegment.end ?? {};
|
||||
const points = Array.isArray(activeSegment.samples) && activeSegment.samples.length > 1 ? activeSegment.samples.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
|
||||
if (points.length > 1) {
|
||||
const progress = Math.max(0, Math.min(1, renderedProgress));
|
||||
const lastIndex = Math.max(1, Math.floor(progress * (points.length - 1)) + 1);
|
||||
const travelled = points.slice(0, lastIndex);
|
||||
const livePoint = sourcePoint(renderedPose);
|
||||
const lastPoint = travelled.at(-1);
|
||||
if (!lastPoint || lastPoint.distanceTo(livePoint) > 1e-9) travelled.push(livePoint);
|
||||
if (travelled.length > 1) {
|
||||
const liveLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(travelled), live);
|
||||
liveLine.renderOrder = 20;
|
||||
liveGroup.add(liveLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
liveKey = nextLiveKey;
|
||||
}
|
||||
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
|
||||
grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4));
|
||||
originGroup.scale.setScalar(Math.max(.35, bounds.radius * .55));
|
||||
const pose = sourcePoint(renderedPose);
|
||||
toolGroup.position.copy(pose);
|
||||
toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0);
|
||||
const toolScale = Math.min(1, Math.max(0.15, bounds.radius * 0.55));
|
||||
toolGroup.scale.setScalar(toolScale);
|
||||
tool.position.set(0, .57, 0); tip.position.set(0, 0, 0);
|
||||
grid.visible = true; limitGrid.visible = layer.showLimits; offsetAxes.visible = layer.showOffsets;
|
||||
offsetAxes.position.copy(sourcePoint({ x: state.offsets.x_rot_point, y: state.offsets.y_offset, z: state.offsets.z_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)}\nHelix ${segments.filter((segment) => segment.kind === "helix").length} / Samples ${segments.reduce((count, segment) => count + (Array.isArray(segment.samples) ? segment.samples.length : 0), 0)}\nLive ${renderedActiveSegment || "-"} / ${renderedProgress.toFixed(3)}\nTick ${renderedTick}\nHash ${state.stateHash.slice(0, 18)}`;
|
||||
controls.update(); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
return () => { cancelAnimationFrame(frame); observer.disconnect(); clearLines(pathGroup); clearLines(liveGroup); completed.dispose(); rapid.dispose(); live.dispose(); liveHistory.dispose(); originGroup.traverse((child) => { const mesh = child as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material as (THREE.Material & { map?: THREE.Texture }) | undefined; material?.map?.dispose?.(); material?.dispose?.(); }); controls.dispose(); delete (element as typeof element & { xyzacController?: unknown }).xyzacController; renderer.dispose(); element.replaceChildren(); };
|
||||
}, []);
|
||||
|
||||
const pose = visual.pose;
|
||||
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) => <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
|
||||
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-wasm-motion-queue" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{`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]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}`}</pre></div>;
|
||||
}
|
||||
|
||||
export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
|
||||
return <XyzacToolPreview visual={projectXyzacVisual(snapshot)} {...props} />;
|
||||
}
|
||||
50
web/react/src/XyzacTrtPanel.tsx
Normal file
50
web/react/src/XyzacTrtPanel.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { XYZAC_KINEMATICS, type XyzacOperationTrace, type XyzacPanelView } from "./xyzac-panel-contract";
|
||||
|
||||
type Command = (kind: string, payload?: Record<string, unknown>, action?: string) => void;
|
||||
|
||||
export function XyzacKinematicsControl({ view, command }: { view: XyzacPanelView; command: Command }) {
|
||||
const active = view.kinematics?.active_type;
|
||||
return <section className="xyzac-kinematics" aria-label="XYZAC kinematics">
|
||||
<div className="xyzac-section-title"><strong>Switchkins</strong><span>{view.kinematics?.native_type ?? "Unavailable"}</span></div>
|
||||
<div className="xyzac-segments" role="group" aria-label="Kinematics mode">
|
||||
{XYZAC_KINEMATICS.map((item) => {
|
||||
const gate = view.gates[item.action];
|
||||
const disabled = view.status !== "ready" || gate?.enabled === false;
|
||||
return <button key={item.type} type="button" className={active === item.type ? "active" : ""} aria-pressed={active === item.type} disabled={disabled} title={gate?.reason ?? item.mcode} onClick={() => command("KINEMATICS_SELECT", { type: item.type }, item.action)}><span>{item.label}</span><small>{item.mcode}</small></button>;
|
||||
})}
|
||||
</div>
|
||||
<button type="button" className="xyzac-clear" title={view.gates.vismachClear?.reason ?? "Clear visual plot"} disabled={view.gates.vismachClear?.enabled === false} onClick={() => command("VISUAL_CLEAR", {}, "vismachClear")}>Clear plot</button>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function XyzacManualPanel({ view }: { view: XyzacPanelView }) {
|
||||
const pose = view.pose;
|
||||
const axes = ["x", "y", "z", "a", "c"] as const;
|
||||
return <section className="xyzac-manual-summary" aria-label="XYZAC authoritative position">
|
||||
<div className="xyzac-section-title"><strong>XYZAC authority</strong><span>{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}</span></div>
|
||||
<div className="xyzac-position-grid">
|
||||
{axes.map((axis, index) => <div key={axis}><b>{axis.toUpperCase()}</b><output>{Number(pose?.[axis] ?? 0).toFixed(axis === "a" || axis === "c" ? 3 : 4)}</output><small>J{index} {Number(view.joints[index] ?? 0).toFixed(4)}</small></div>)}
|
||||
</div>
|
||||
<div className="xyzac-offset-row"><span>Tool {Number(view.kinematics?.offsets.tool_offset ?? 0).toFixed(3)}</span><span>Y offset {Number(view.kinematics?.offsets.y_offset ?? 0).toFixed(3)}</span><span>Z offset {Number(view.kinematics?.offsets.z_offset ?? 0).toFixed(3)}</span></div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function XyzacProgramPane({ status, currentLine, sourceHash, children }: { status: string; currentLine: number | null; sourceHash?: string | null; children: ReactNode }) {
|
||||
return <section className="xyzac-program" aria-label="XYZAC program"><header><strong>XYZAC Program</strong><span>{status}</span><span>Line {currentLine ?? "-"}</span><code>{sourceHash?.slice(0, 18) ?? "no source"}</code></header>{children}</section>;
|
||||
}
|
||||
|
||||
export function XyzacDiagnostics({ view, operations, alarms, error }: { view: XyzacPanelView; operations: XyzacOperationTrace[]; alarms: Array<Record<string, unknown>>; error: { code: string; message?: string } | null }) {
|
||||
const disabled = Object.entries(view.gates).filter(([, gate]) => gate.enabled === false);
|
||||
return <section className="xyzac-diagnostics" aria-label="XYZAC diagnostics">
|
||||
<div className="xyzac-diagnostic-summary"><strong>{view.status.toUpperCase()}</strong><span>Tick {view.tick}</span><code>{view.stateHash || "no hash"}</code></div>
|
||||
{error && <div className="xyzac-error" role="alert"><b>{error.code}</b><span>{error.message}</span></div>}
|
||||
<div><h3>Operation trace</h3>{operations.length ? operations.slice(-8).reverse().map((item, index) => <div className="xyzac-diagnostic-row" key={`${item.operation_id ?? "operation"}-${index}`}><code>{item.operation_id ?? "-"}</code><span>{item.kind}</span><b>{item.phase}</b><span>{item.reason ?? item.response_kind}</span></div>) : <span>No operations</span>}</div>
|
||||
<div><h3>Alarms / recovery</h3>{alarms.length ? alarms.slice(-5).map((alarm, index) => <div className="xyzac-diagnostic-row" key={`${String(alarm.id ?? alarm.code ?? "alarm")}-${index}`}><code>{String(alarm.code ?? "alarm")}</code><span>{String(alarm.severity ?? "error")}</span></div>) : <span>No alarms</span>}<div className="xyzac-diagnostic-row"><code>recovery</code><span>{view.recovery ? JSON.stringify(view.recovery) : "idle"}</span></div></div>
|
||||
<div><h3>Gate reasons</h3>{disabled.length ? disabled.slice(0, 10).map(([name, gate]) => <div className="xyzac-diagnostic-row" key={name}><code>{name}</code><span>{gate.reason}</span></div>) : <span>All XYZAC gates enabled</span>}</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function XyzacTrtPanel({ view, command }: { view: XyzacPanelView; command: Command }) {
|
||||
return <section className="xyzac-control-band" aria-label="XYZAC TRT controls"><XyzacKinematicsControl view={view} command={command} /><XyzacManualPanel view={view} /></section>;
|
||||
}
|
||||
1
web/react/src/legacy.d.ts
vendored
1
web/react/src/legacy.d.ts
vendored
@@ -3,6 +3,7 @@ declare module "*.mjs" {
|
||||
export function loadSimulationCase(options: { path: string; baseUrl?: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
|
||||
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
|
||||
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
|
||||
export class XyzacSession extends SimSession { authority(): Record<string, unknown>; }
|
||||
export class AxisOperationController {
|
||||
constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise<unknown> });
|
||||
execute(kind: string, payload?: Record<string, unknown>, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
|
||||
|
||||
@@ -56,6 +56,37 @@ export type SimSnapshotV1 = {
|
||||
panel_snapshot?: PanelSnapshotV1;
|
||||
configuration?: Record<string, unknown> | null;
|
||||
visual?: PanelSnapshotV1["visual"];
|
||||
kinematics?: XyzacKinematicsSnapshot;
|
||||
xyzac?: XyzacCapabilitySnapshot;
|
||||
gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
|
||||
};
|
||||
|
||||
export type XyzacKinematicsSnapshot = {
|
||||
schema: "wasm-xyzac-kinematics.v1";
|
||||
status: "verified" | "error" | "blocked";
|
||||
active_type: 0 | 1 | 2;
|
||||
native_type: string;
|
||||
coordinates: "XYZAC";
|
||||
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 };
|
||||
generation: number;
|
||||
switch_count: number;
|
||||
state_hash: string;
|
||||
wasm_state_hash: string;
|
||||
};
|
||||
|
||||
export type XyzacCapabilitySnapshot = {
|
||||
coordinates: "XYZAC";
|
||||
joint_count: 5;
|
||||
active_type: 0 | 1 | 2;
|
||||
hal_pin_vars: boolean;
|
||||
switchkins_net: boolean;
|
||||
finite_offsets: boolean;
|
||||
userk_capability: boolean;
|
||||
};
|
||||
|
||||
export type PanelSnapshotV1 = {
|
||||
@@ -81,13 +112,18 @@ export type PanelSnapshotV1 = {
|
||||
schema: "axis-visual.v1";
|
||||
pose: Record<string, number>;
|
||||
joints: number[];
|
||||
toolpath: Array<Record<string, unknown>>;
|
||||
toolpath?: Array<Record<string, unknown>>;
|
||||
active_segment_id?: number | null;
|
||||
active_progress?: number;
|
||||
motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>;
|
||||
source: "wasm_canonical_motion";
|
||||
};
|
||||
authoritative: Record<string, unknown>;
|
||||
motion_authority: string;
|
||||
task_authority: string;
|
||||
kinematics?: XyzacKinematicsSnapshot;
|
||||
xyzac?: XyzacCapabilitySnapshot;
|
||||
io?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Capabilities = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession } from "../../worker/sim-session.mjs";
|
||||
import { XyzacSession as SimSession } from "../../worker/xyzac-session.mjs";
|
||||
|
||||
// The boot event is a transport diagnostic; it carries no simulation state.
|
||||
self.postMessage({ schema_version: 1, kind: "WORKER_BOOT", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "booting", runtime: "react-vite-worker" } });
|
||||
|
||||
@@ -57,7 +57,7 @@ function payload<T>(message: MessageEnvelope): T {
|
||||
function projection(result: Record<string, unknown>, message: MessageEnvelope, previous: FrontendState) {
|
||||
const panel = result.panel_snapshot as PanelSnapshotV1 | undefined;
|
||||
if (panel?.schema === "axis-panel-snapshot.v1") {
|
||||
const visualSegments = panel.visual.toolpath
|
||||
const visualSegments = (Array.isArray(panel.visual.toolpath) ? panel.visual.toolpath : previous.toolpath)
|
||||
.filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0))
|
||||
.map((segment, index) => {
|
||||
const value = segment as Record<string, unknown>;
|
||||
@@ -68,13 +68,22 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
|
||||
end: value.end as Record<string, number>,
|
||||
...(value.center ? { center: value.center as Record<string, number> } : {}),
|
||||
...(value.normal ? { normal: value.normal as Record<string, number> } : {}),
|
||||
...(Array.isArray(value.samples) ? { samples: value.samples as Array<Record<string, number>> } : {}),
|
||||
...(typeof value.samples_source === "string" ? { samples_source: value.samples_source } : {}),
|
||||
...(typeof value.kind === "string" ? { kind: value.kind } : {}),
|
||||
...(typeof value.helix === "boolean" ? { helix: value.helix } : {}),
|
||||
...(typeof value.radius === "number" ? { radius: value.radius } : {}),
|
||||
...(typeof value.turns === "number" ? { turns: value.turns } : {}),
|
||||
...(typeof value.axial_delta === "number" ? { axial_delta: value.axial_delta } : {}),
|
||||
...(typeof value.canonical_motion_mode === "number" ? { canonical_motion_mode: value.canonical_motion_mode } : {}),
|
||||
...(typeof value.kinematics_mode === "number" ? { kinematics_mode: value.kinematics_mode } : {}),
|
||||
...(typeof value.motion_type === "number" ? { motion_type: value.motion_type } : {}),
|
||||
...(typeof value.turn === "number" ? { turn: value.turn } : {}),
|
||||
active: value.id === panel.visual.active_segment_id,
|
||||
};
|
||||
});
|
||||
const pose = panel.visual.pose;
|
||||
const axes = [pose.x, pose.y, pose.z].map((value) => Number.isFinite(value) ? value : 0);
|
||||
const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0);
|
||||
const snapshot = {
|
||||
schema: panel.schema,
|
||||
simulation_tick: panel.simulation_tick,
|
||||
@@ -85,17 +94,20 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
|
||||
overrides: panel.overrides,
|
||||
wasm_motion: panel.wasm_motion,
|
||||
authoritative: panel.authoritative as SimSnapshotV1["authoritative"],
|
||||
configuration: panel.configuration as SimSnapshotV1["configuration"],
|
||||
visual: panel.visual,
|
||||
configuration: (panel.configuration ?? previous.snapshot?.configuration ?? null) as SimSnapshotV1["configuration"],
|
||||
visual: { ...panel.visual, toolpath: visualSegments },
|
||||
kinematics: panel.kinematics,
|
||||
xyzac: panel.xyzac,
|
||||
gates: panel.gates,
|
||||
} satisfies SimSnapshotV1;
|
||||
return {
|
||||
snapshot,
|
||||
toolpath: visualSegments,
|
||||
task: panel.task,
|
||||
program: panel.program,
|
||||
devices: panel.devices,
|
||||
deviceIo: panel.device_io,
|
||||
hal: panel.hal,
|
||||
devices: panel.devices ?? previous.devices,
|
||||
deviceIo: panel.device_io ?? previous.deviceIo,
|
||||
hal: panel.hal ?? previous.hal,
|
||||
axisState: panel.axis_state,
|
||||
gates: { ...panel.gates, jog: panel.gates.jogStart, load: panel.gates.load, run: panel.gates.run, step: panel.gates.step, pause: panel.gates.pause, resume: panel.gates.resume, abort: panel.gates.abort, mdi: panel.gates.mdi, home: panel.gates.home },
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-height: 100%; margin: 0; }
|
||||
html, body, #root { min-height: 100%; margin: 0; overflow-x: hidden; }
|
||||
body { min-width: 320px; background: #cfcfcf; color: var(--ink); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
@@ -31,7 +31,7 @@ button { color: inherit; }
|
||||
.connection-state.ready, .connection-state.running { color: #185c1d; }
|
||||
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
|
||||
|
||||
.axis-menubar { display: flex; align-items: stretch; min-height: 27px; padding-left: 3px; border-bottom: 1px solid #888; background: #d3d3d3; }
|
||||
.axis-menubar { display: flex; align-items: stretch; min-width: 0; min-height: 27px; padding-left: 3px; overflow-x: auto; border-bottom: 1px solid #888; background: #d3d3d3; }
|
||||
.axis-menu-item { position: relative; }
|
||||
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
|
||||
.axis-menu-item summary::-webkit-details-marker { display: none; }
|
||||
@@ -41,7 +41,7 @@ button { color: inherit; }
|
||||
.axis-menu-popup button:hover, .axis-menu-popup button:focus-visible { outline: 1px solid var(--focus); background: #c8d7e8; }
|
||||
.axis-menu-popup kbd { margin-left: 22px; color: #555; font: 11px ui-monospace, monospace; }
|
||||
|
||||
.axis-toolbar { display: flex; align-items: center; min-height: 35px; padding: 3px 5px; overflow-x: auto; border-bottom: 1px solid #8a8a8a; background: #d9d9d9; }
|
||||
.axis-toolbar { display: flex; align-items: center; width: 100%; min-width: 0; min-height: 35px; padding: 3px 5px; overflow-x: auto; border-bottom: 1px solid #8a8a8a; background: #d9d9d9; }
|
||||
.axis-tool-button { display: inline-grid; width: 29px; height: 29px; flex: 0 0 29px; place-items: center; padding: 2px; border: 1px solid transparent; border-radius: 2px; background: transparent; cursor: pointer; }
|
||||
.axis-tool-button img { width: 24px; height: 24px; object-fit: contain; pointer-events: none; }
|
||||
.axis-tool-button:hover, .axis-tool-button:focus-visible { border-color: #6a8db6; background: #c8d7e8; outline: none; }
|
||||
@@ -50,14 +50,60 @@ button { color: inherit; }
|
||||
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
|
||||
|
||||
.axis-case-strip { display: flex; align-items: center; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
|
||||
.axis-case-strip label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
|
||||
.axis-case-strip select { max-width: min(420px, 46vw); min-height: 24px; border: 1px solid var(--border-dark); background: white; }
|
||||
.axis-case-strip { display: flex; align-items: center; width: 100%; min-width: 0; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
|
||||
.axis-case-strip label { display: inline-flex; align-items: center; min-width: 0; flex: 0 0 auto; gap: 5px; font-size: 12px; }
|
||||
.axis-case-strip select { width: min(420px, 46vw); max-width: 100%; min-height: 24px; border: 1px solid var(--border-dark); background: white; }
|
||||
.axis-case-strip > .axis-text-button { min-width: 68px; white-space: nowrap; }
|
||||
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.case-status { min-width: 0; flex: 1 1 auto; }
|
||||
.command-status { margin-left: auto; }
|
||||
|
||||
.machine-sequence { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 6px 9px; border-bottom: 1px solid #858585; background: #cfcfcf; }
|
||||
.machine-sequence-heading { display: grid; gap: 2px; min-width: 148px; }
|
||||
.machine-sequence-heading strong { font-size: 12px; }
|
||||
.machine-sequence-heading span { color: #555; font: 10px ui-monospace, monospace; }
|
||||
.machine-sequence-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; }
|
||||
.machine-sequence-actions .axis-text-button { min-height: 28px; }
|
||||
.machine-sequence-actions .axis-text-button:first-child { border-color: #9b4a4a; }
|
||||
|
||||
.xyzac-control-band { display: grid; grid-template-columns: minmax(330px, .8fr) minmax(470px, 1.5fr); gap: 12px; padding: 7px 10px; border-bottom: 1px solid #858585; background: #d5d5d5; }
|
||||
.xyzac-kinematics, .xyzac-manual-summary { min-width: 0; }
|
||||
.xyzac-section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 5px; font-size: 11px; }
|
||||
.xyzac-section-title strong { font-size: 12px; }
|
||||
.xyzac-section-title span { overflow: hidden; color: #555; font: 10px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.xyzac-segments { display: grid; grid-template-columns: repeat(3, minmax(86px, 1fr)); min-height: 38px; }
|
||||
.xyzac-segments button { display: grid; place-content: center; min-width: 0; min-height: 38px; padding: 3px 7px; border: 1px solid #777; border-right-width: 0; border-radius: 0; background: #ededed; cursor: pointer; }
|
||||
.xyzac-segments button:first-child { border-radius: 3px 0 0 3px; }
|
||||
.xyzac-segments button:last-child { border-right-width: 1px; border-radius: 0 3px 3px 0; }
|
||||
.xyzac-segments button.active { color: #fff; background: #315f8b; box-shadow: inset 0 0 0 1px #173b5e; }
|
||||
.xyzac-segments button:disabled { color: #777; cursor: not-allowed; background: #c9c9c9; }
|
||||
.xyzac-segments button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.xyzac-segments button small { font: 9px ui-monospace, monospace; }
|
||||
.xyzac-clear { min-height: 24px; margin-top: 5px; padding: 2px 9px; border: 1px solid #858585; border-radius: 2px; background: #eee; cursor: pointer; }
|
||||
.xyzac-position-grid { display: grid; grid-template-columns: repeat(5, minmax(74px, 1fr)); border: 1px solid #909090; background: #f4f4f4; }
|
||||
.xyzac-position-grid > div { display: grid; grid-template-columns: 18px 1fr; align-items: baseline; min-width: 0; padding: 4px 6px; border-right: 1px solid #aaa; }
|
||||
.xyzac-position-grid > div:last-child { border-right: 0; }
|
||||
.xyzac-position-grid b { grid-row: span 2; font-size: 12px; }
|
||||
.xyzac-position-grid output { overflow: hidden; font-size: 13px; text-overflow: ellipsis; }
|
||||
.xyzac-position-grid small { overflow: hidden; color: #666; font: 9px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.xyzac-offset-row { display: flex; gap: 12px; margin-top: 5px; color: #555; font: 10px ui-monospace, monospace; }
|
||||
.xyzac-program { display: contents; }
|
||||
.xyzac-program > header { display: flex; align-items: center; gap: 14px; min-height: 25px; margin: 0 5px; padding: 3px 8px; border: 1px solid #888; border-bottom: 0; background: #d4d4d4; font-size: 11px; }
|
||||
.xyzac-program > header code { margin-left: auto; color: #4d4d4d; }
|
||||
.xyzac-diagnostics { display: grid; gap: 8px; padding: 10px; border-top: 1px solid #aaa; background: #ededed; font-size: 11px; }
|
||||
.xyzac-diagnostics h3 { margin: 0 0 3px; font-size: 11px; }
|
||||
.xyzac-diagnostic-summary, .xyzac-diagnostic-row, .xyzac-error { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.xyzac-diagnostic-summary code, .xyzac-diagnostic-row span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.xyzac-diagnostic-row code { min-width: 128px; }
|
||||
.xyzac-error { padding: 5px; color: #7b1111; border: 1px solid #bd8585; background: #f7dddd; }
|
||||
|
||||
.axis-top-pane { display: grid; grid-template-columns: minmax(360px, .48fr) minmax(430px, 1fr); height: clamp(360px, calc(100vh - 415px), 680px); min-height: 360px; flex: 0 0 auto; padding: 5px; gap: 5px; }
|
||||
.axis-notebook { min-width: 0; display: flex; flex-direction: column; border: 1px solid var(--border-dark); background: var(--face-light); }
|
||||
.axis-notebook { min-width: 0; min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--border-dark); background: var(--face-light); }
|
||||
.axis-notebook > [role="tabpanel"] { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
|
||||
.axis-notebook > [role="tabpanel"] > .manual-control,
|
||||
.axis-notebook > [role="tabpanel"] > .mdi-control,
|
||||
.axis-notebook > [role="tabpanel"] > .dro-view,
|
||||
.axis-notebook > [role="tabpanel"] > .axis-preview { height: 100%; min-height: 0; }
|
||||
.notebook-tabs { display: flex; align-items: end; min-height: 29px; padding: 3px 3px 0; border-bottom: 1px solid #777; background: #c5c5c5; }
|
||||
.notebook-tabs button { min-height: 26px; padding: 4px 12px; border: 1px solid transparent; border-bottom: 0; background: transparent; cursor: pointer; }
|
||||
.notebook-tabs button.active { margin-bottom: -1px; border-color: #777; background: var(--face-light); font-weight: 700; }
|
||||
@@ -104,6 +150,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.three-viewer { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; border: 1px solid #303030; background: #000; }
|
||||
.three-viewer canvas { display: block; width: 100%; height: 100%; }
|
||||
.axis-preview-hud { position: absolute; z-index: 1; top: 11px; left: 34px; margin: 0; color: #f2f2f2; font: 12px/1.45 ui-monospace, monospace; white-space: pre; pointer-events: none; text-shadow: 0 1px #000; }
|
||||
.xyzac-view-controller { position: absolute; z-index: 2; top: 8px; right: 8px; display: grid; grid-template-columns: repeat(2, 27px); gap: 3px; padding: 4px; border: 1px solid #68747c; background: rgba(12, 18, 22, .88); box-shadow: 0 1px 4px #0008; }
|
||||
.xyzac-view-controller button { width: 27px; height: 25px; padding: 0; border: 1px solid #93a1a8; background: #e6ebed; color: #182126; font: 700 14px/1 ui-monospace, monospace; cursor: pointer; }
|
||||
.xyzac-view-controller button:hover, .xyzac-view-controller button:focus-visible { background: #fff; outline: 2px solid #e7b84b; outline-offset: 1px; }
|
||||
.xyzac-controller-divider { width: 100%; height: 1px; grid-column: 1 / -1; background: #68747c; }
|
||||
.dro-view { background: #ececec; }
|
||||
.dro-mode-row, .dro-column-headings, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
|
||||
.dro-mode-row { padding: 5px 6px; border-bottom: 1px solid #888; color: #555; font-size: 11px; }
|
||||
@@ -141,16 +191,17 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.visually-hidden-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.axis-layer-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 4px; color: #4d4d4d; font-size: 11px; }
|
||||
.axis-layer-controls input { margin: 0; }
|
||||
.axis-info-bar { display: flex; align-items: stretch; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; }
|
||||
.axis-info-bar > span { display: flex; align-items: center; min-height: 25px; padding: 2px 9px; border-right: 1px solid #999; font: 12px ui-monospace, monospace; }
|
||||
.axis-info-bar { display: flex; align-items: stretch; width: 100%; min-width: 0; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; }
|
||||
.axis-info-bar > span { display: flex; align-items: center; min-width: 0; min-height: 25px; padding: 2px 9px; overflow: hidden; border-right: 1px solid #999; font: 12px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-state { min-width: 92px; background: #ececec; font-weight: 700 !important; }
|
||||
.task-state.running { color: #145d1c; }
|
||||
.task-state.blocked, .task-state.error { color: #9b111e; background: #f2d6d6; }
|
||||
.tool-state { flex: 1; }
|
||||
.tool-state { min-width: 0; flex: 1; }
|
||||
.position-state { min-width: 230px; }
|
||||
.footer-status { min-width: 180px; color: #505050; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.xyzac-control-band { grid-template-columns: 1fr; }
|
||||
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
|
||||
.axis-notebook { min-height: 330px; }
|
||||
.axis-preview { min-height: 320px; }
|
||||
@@ -163,6 +214,15 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.axis-case-strip { flex-wrap: wrap; }
|
||||
.axis-case-strip select { max-width: 62vw; }
|
||||
.command-status { margin-left: 0; width: 100%; }
|
||||
.machine-sequence { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.machine-sequence-heading { min-width: 0; }
|
||||
.xyzac-control-band { padding: 6px; }
|
||||
.xyzac-position-grid { grid-template-columns: repeat(5, minmax(54px, 1fr)); }
|
||||
.xyzac-position-grid > div { grid-template-columns: 14px 1fr; padding: 3px; }
|
||||
.xyzac-position-grid output { font-size: 10px; }
|
||||
.xyzac-offset-row { flex-wrap: wrap; gap: 4px 9px; }
|
||||
.xyzac-program > header { flex-wrap: wrap; gap: 4px 10px; }
|
||||
.xyzac-program > header code { width: 100%; margin-left: 0; }
|
||||
.manual-grid, .manual-sliders, .axis-slider { grid-template-columns: 1fr; }
|
||||
.manual-axis-block, .manual-coolant-block { grid-row: auto; grid-column: auto; }
|
||||
.dro-mode-row, .dro-column-headings, .dro-row { grid-template-columns: 27px repeat(3, minmax(0, 1fr)); gap: 4px; }
|
||||
|
||||
44
web/react/src/test_xyzac_threejs.mjs
Normal file
44
web/react/src/test_xyzac_threejs.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { projectXyzacVisual, XYZAC_MACHINE_GROUPS } from "./xyzac-visual-adapter.ts";
|
||||
|
||||
const source = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8");
|
||||
const adapter = await readFile(new URL("./xyzac-visual-adapter.ts", import.meta.url), "utf8");
|
||||
const snapshot = {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
simulation_tick: 42,
|
||||
state_hash: "fnv1a64:xyzac-authoritative",
|
||||
kinematics: {
|
||||
schema: "wasm-xyzac-kinematics.v1",
|
||||
status: "verified",
|
||||
active_type: 1,
|
||||
native_type: "xyzac-trt-kins",
|
||||
coordinates: "XYZAC",
|
||||
joint_count: 5,
|
||||
linear_units: "mm",
|
||||
angular_units: "deg",
|
||||
pose: { x: 12, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 },
|
||||
joints: [12, -5, 20, 30, 45],
|
||||
offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 25 },
|
||||
generation: 2,
|
||||
switch_count: 2,
|
||||
state_hash: "fnv1a64:kinematics",
|
||||
wasm_state_hash: "fnv1a64:wasm-kinematics",
|
||||
},
|
||||
visual: { active_segment_id: 7, active_progress: 0.5, pose: { x: 12, y: -5, z: 20, a: 30, c: 45 }, joints: [12, -5, 20, 30, 45], toolpath: [{ id: 7, active: true, start: { x: 0, y: 0, z: 0 }, end: { x: 12, y: -5, z: 20 }, motion_type: 1 }] },
|
||||
};
|
||||
const visual = projectXyzacVisual(snapshot);
|
||||
assert.equal(visual.schema, "xyzac-visual-state.v1");
|
||||
assert.equal(visual.tick, 42);
|
||||
assert.equal(visual.stateHash, snapshot.state_hash);
|
||||
assert.deepEqual(visual.joints, [12, -5, 20, 30, 45]);
|
||||
assert.deepEqual(visual.pose, snapshot.kinematics.pose);
|
||||
assert.deepEqual(visual.offsets, snapshot.kinematics.offsets);
|
||||
assert.equal(visual.activeSegmentId, 7);
|
||||
assert.equal(visual.activeProgress, 0.5);
|
||||
assert.equal(visual.source, "linuxcnc-wasm-worker");
|
||||
assert.deepEqual(XYZAC_MACHINE_GROUPS, ["xyzac-machine", "table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "xyzac-toolpath", "xyzac-live-toolpath"]);
|
||||
for (const token of ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "tableX.position.x = -tableXJoint * MM", "saddle.position.z = -saddleYJoint * MM", "zslide.position.y = (200 - spindleZJoint) * MM", "tilt.rotation.x = -tiltAJoint * DEG", "rotate.rotation.y = -rotateCJoint * DEG", "tool.position.y = -state.offsets.tool_offset * MM", "data-xyzac-tick", "data-xyzac-state-hash", "data-xyzac-active-segment", "data-xyzac-joints", "data-xyzac-offsets"]) assert.ok(source.includes(token), token);
|
||||
for (const forbidden of [/accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/, /HAL_SET/, /KINEMATICS_SELECT/]) assert.equal(forbidden.test(source), false, String(forbidden));
|
||||
assert.ok(adapter.includes("source: \"linuxcnc-wasm-worker\""));
|
||||
console.log(JSON.stringify({ schema: "xyzac-threejs-rtcp-10.v1", status: "verified", tick: visual.tick, state_hash: visual.stateHash, groups: XYZAC_MACHINE_GROUPS, active_segment: visual.activeSegmentId, direction: { table_x: -1, saddle_y: -1, spindle_z: -1, tilt_a: "x+", rotate_c: "z+", tool_offset: "z-" }, clear_visual_nonmutating: true, source: visual.source }));
|
||||
22
web/react/src/test_xyzac_tool_preview.mjs
Normal file
22
web/react/src/test_xyzac_tool_preview.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { projectXyzacVisual } from "./xyzac-visual-adapter.ts";
|
||||
|
||||
const source = await readFile(new URL("./XyzacToolPreview.tsx", import.meta.url), "utf8");
|
||||
const visual = projectXyzacVisual({
|
||||
simulation_tick: 12,
|
||||
state_hash: "fnv1a64:tool-preview",
|
||||
kinematics: { schema: "wasm-xyzac-kinematics.v1", status: "verified", active_type: 1, native_type: "xyzac-trt-kins", coordinates: "XYZAC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: { x: 10, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 }, joints: [10, -5, 20, 30, 45], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 5 }, generation: 1, switch_count: 1, state_hash: "fnv1a64:k", wasm_state_hash: "fnv1a64:w" },
|
||||
visual: { active_segment_id: 3, active_progress: 0.375, pose: { x: 11, y: -4, z: 19, a: 31, c: 44 }, joints: [11, -4, 19, 31, 44], toolpath: [{ id: 3, active: true, motion_type: 1, start: { x: 0, y: 0, z: 0 }, end: { x: 10, y: -5, z: 20 } }] },
|
||||
});
|
||||
assert.equal(visual.tick, 12);
|
||||
assert.equal(visual.stateHash, "fnv1a64:tool-preview");
|
||||
assert.deepEqual(visual.joints, [11, -4, 19, 31, 44]);
|
||||
assert.deepEqual(visual.pose, { x: 11, y: -4, z: 19, a: 31, b: 0, c: 44, u: 0, v: 0, w: 0 });
|
||||
assert.deepEqual(visual.livePose, visual.pose);
|
||||
assert.equal(visual.activeProgress, 0.375);
|
||||
assert.equal(visual.activeSegmentId, 3);
|
||||
for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "segment.samples.map(sourcePoint)", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token);
|
||||
for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden));
|
||||
for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden));
|
||||
console.log(JSON.stringify({ schema: "xyzac-tool-preview-rtcp-10b.v1", status: "verified", tick: visual.tick, state_hash: visual.stateHash, active_segment: visual.activeSegmentId, tool_pose: visual.pose, joints: visual.joints, machine_scene_nodes: 0, canonical_toolpath_segments: visual.toolpath.length, source: visual.source }));
|
||||
59
web/react/src/xyzac-panel-contract.ts
Normal file
59
web/react/src/xyzac-panel-contract.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { XyzacCapabilitySnapshot, XyzacKinematicsSnapshot } from "./protocol";
|
||||
|
||||
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 XYZAC_COMMANDS = {
|
||||
power: "POWER",
|
||||
selectKinematics: "KINEMATICS_SELECT",
|
||||
clearVisual: "VISUAL_CLEAR",
|
||||
} as const;
|
||||
export const XYZAC_KINEMATICS = [
|
||||
{ type: 0, action: "switchkinsIdentity", label: "Identity", mcode: "M429" },
|
||||
{ type: 1, action: "switchkinsXyzac", label: "TCP: XYZAC", mcode: "M428" },
|
||||
{ type: 2, action: "switchkinsUserk", label: "User K", mcode: "M430" },
|
||||
] as const;
|
||||
|
||||
export type XyzacGate = { action: string; enabled: boolean; reason: string; required_type?: number; visual_only?: boolean };
|
||||
export type XyzacOperationTrace = { operation_id?: string; kind?: string; action?: string | null; phase?: string; reason?: string; response_kind?: string };
|
||||
export type XyzacPanelView = {
|
||||
status: "ready" | "unavailable";
|
||||
tick: number;
|
||||
stateHash: string;
|
||||
kinematics: XyzacKinematicsSnapshot | null;
|
||||
capability: XyzacCapabilitySnapshot | null;
|
||||
pose: XyzacKinematicsSnapshot["pose"] | null;
|
||||
joints: number[];
|
||||
gates: Record<string, XyzacGate>;
|
||||
recovery: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type XyzacPanelSource = {
|
||||
simulation_tick?: number;
|
||||
state_hash?: string;
|
||||
kinematics?: XyzacKinematicsSnapshot;
|
||||
xyzac?: XyzacCapabilitySnapshot;
|
||||
gates?: Record<string, XyzacGate>;
|
||||
authoritative?: Record<string, unknown>;
|
||||
visual?: { pose?: Record<string, number>; joints?: number[] };
|
||||
};
|
||||
|
||||
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 visualPose = snapshot?.visual?.pose;
|
||||
const pose = visualPose && [visualPose.x, visualPose.y, visualPose.z, visualPose.a, visualPose.c].every((value) => Number.isFinite(value))
|
||||
? { ...(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] : [];
|
||||
return {
|
||||
status: kinematics && capability ? "ready" : "unavailable",
|
||||
tick: snapshot?.simulation_tick ?? 0,
|
||||
stateHash: snapshot?.state_hash ?? "",
|
||||
kinematics,
|
||||
capability,
|
||||
pose,
|
||||
joints,
|
||||
gates: snapshot?.gates ?? {},
|
||||
recovery: (snapshot?.authoritative?.recovery as Record<string, unknown> | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
57
web/react/src/xyzac-visual-adapter.ts
Normal file
57
web/react/src/xyzac-visual-adapter.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { XyzacKinematicsSnapshot } from "./protocol";
|
||||
|
||||
export type XyzacVisualState = {
|
||||
schema: "xyzac-visual-state.v1";
|
||||
tick: number;
|
||||
stateHash: string;
|
||||
pose: XyzacKinematicsSnapshot["pose"];
|
||||
joints: [number, number, number, number, number];
|
||||
livePose: XyzacKinematicsSnapshot["pose"];
|
||||
activeProgress: number;
|
||||
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
|
||||
offsets: XyzacKinematicsSnapshot["offsets"];
|
||||
activeSegmentId: number | null;
|
||||
toolpath: Array<Record<string, unknown>>;
|
||||
source: "linuxcnc-wasm-worker";
|
||||
};
|
||||
|
||||
const ZERO_POSE: XyzacKinematicsSnapshot["pose"] = { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 };
|
||||
const ZERO_OFFSETS: XyzacKinematicsSnapshot["offsets"] = { x_rot_point: 0, y_offset: 0, z_offset: 0, tool_offset: 0 };
|
||||
|
||||
export type XyzacVisualSource = {
|
||||
simulation_tick?: number;
|
||||
state_hash?: string;
|
||||
kinematics?: XyzacKinematicsSnapshot;
|
||||
visual?: { active_segment_id?: number | null; active_progress?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
|
||||
const kinematics = snapshot?.kinematics;
|
||||
const visualPose = snapshot?.visual?.pose;
|
||||
const pose = visualPose && [visualPose.x, visualPose.y, visualPose.z, visualPose.a, visualPose.c].every((value) => Number.isFinite(value))
|
||||
? { ...ZERO_POSE, ...visualPose }
|
||||
: kinematics?.pose ?? ZERO_POSE;
|
||||
const visualJoints = snapshot?.visual?.joints;
|
||||
const joints: [number, number, number, number, number] = visualJoints?.length === 5
|
||||
? visualJoints.slice(0, 5).map((value) => Number(value)) as [number, number, number, number, number]
|
||||
: kinematics?.joints?.length === 5 ? [...kinematics.joints] as [number, number, number, number, number] : [0, 0, 0, 0, 0];
|
||||
const rawProgress = snapshot?.visual?.active_progress;
|
||||
const activeProgress = Number.isFinite(rawProgress) ? Math.max(0, Math.min(1, Number(rawProgress))) : 0;
|
||||
const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.slice(0, 5).map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) }));
|
||||
return {
|
||||
schema: "xyzac-visual-state.v1",
|
||||
tick: snapshot?.simulation_tick ?? 0,
|
||||
stateHash: snapshot?.state_hash ?? "",
|
||||
pose,
|
||||
joints,
|
||||
livePose: pose,
|
||||
activeProgress,
|
||||
motionTrace,
|
||||
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
|
||||
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
|
||||
toolpath: snapshot?.visual?.toolpath ?? [],
|
||||
source: "linuxcnc-wasm-worker",
|
||||
};
|
||||
}
|
||||
|
||||
export const XYZAC_MACHINE_GROUPS = Object.freeze(["xyzac-machine", "table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "xyzac-toolpath", "xyzac-live-toolpath"]);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
|
||||
import { capabilities, validateLimit } from "./capabilities.mjs";
|
||||
import { mapCoreError } from "./error-map.mjs";
|
||||
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
|
||||
import { createCheckpoint, recoverSimulation, validateCheckpoint } from "../workspace/recovery.mjs";
|
||||
import { preflightConfiguration } from "../workspace/configuration.mjs";
|
||||
import { ConfigurationStartupStateMachine } from "../workspace/configuration.mjs";
|
||||
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
|
||||
@@ -23,7 +23,11 @@ import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
|
||||
import { axisState } from "./axis-state.mjs";
|
||||
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||
import { axisGate } from "../app/axis-contract.mjs";
|
||||
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
|
||||
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";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -41,6 +45,7 @@ const OPERATION_KINDS = new Set([
|
||||
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
|
||||
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
|
||||
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
|
||||
"POWER", "KINEMATICS_SELECT", "VISUAL_CLEAR",
|
||||
]);
|
||||
|
||||
const PANEL_GATE_ACTIONS = Object.freeze({
|
||||
@@ -48,6 +53,7 @@ const PANEL_GATE_ACTIONS = Object.freeze({
|
||||
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
|
||||
JOG_START: "jogStart", JOG_STOP: "jogStop", HOME: "home", SET_OVERRIDE: "override",
|
||||
ESTOP: "estop", IO_RECOVER: "recover",
|
||||
POWER: "power",
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
@@ -102,21 +108,33 @@ export class SimSession {
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
this.wasmMotion = new WasmMotionQueue(core);
|
||||
this.wasmHomeLimit = new WasmHomeLimit(core);
|
||||
this.xyzacKinematics = null;
|
||||
this.xyzacIo = null;
|
||||
this.xyzacDeviceIo = null;
|
||||
this.xyzacProgram = null;
|
||||
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
|
||||
this.xyzacRapidVelocity = 35;
|
||||
this.pendingProgramControl = 0;
|
||||
this.motionAuthority = "js";
|
||||
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
|
||||
this.scheduler = new SimScheduler(core);
|
||||
this.configurationState = null;
|
||||
this.halState = null;
|
||||
this.previewState = null;
|
||||
this.workspaceFiles = {};
|
||||
call(core, "sim_protocol_reset");
|
||||
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
|
||||
}
|
||||
|
||||
envelope(kind, input, payload, tick = this.tick) {
|
||||
const statePayload = payload && typeof payload === "object" ? payload : {};
|
||||
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
|
||||
const program = statePayload.program ?? (this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : {});
|
||||
const task = statePayload.task ?? this.task.export();
|
||||
const panelSnapshot = this.panelSnapshot({ task, program, input, tick });
|
||||
// The loaded toolpath is immutable during execution. Avoid cloning all
|
||||
// WASM pose samples into every 10 ms step response; the frontend retains
|
||||
// the canonical path from PROGRAM_LOAD/PROGRAM_RESTART.
|
||||
const lightweightXyzacStep = input.kind === "SIM_STEP" && Boolean(this.xyzacProgram);
|
||||
const panelSnapshot = this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep });
|
||||
return {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
kind,
|
||||
@@ -130,39 +148,114 @@ export class SimSession {
|
||||
panel_snapshot: panelSnapshot,
|
||||
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
|
||||
task_authority: statePayload.task_authority ?? this.task.authority(),
|
||||
wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read(),
|
||||
...(!lightweightXyzacStep ? { wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read() } : {}),
|
||||
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
panelSnapshot({ task = this.task.export(), program = this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick } = {}) {
|
||||
const motion = this.motion.export();
|
||||
wasmMotionProjection() {
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
const position = wasmMotion.status === "verified" ? wasmMotion.state.position : { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 };
|
||||
const home = this.wasmHomeLimit.read();
|
||||
const io = this.xyzacIo?.canonical?.();
|
||||
const ioState = io && typeof io === "object" && io.status !== "blocked" ? io : null;
|
||||
const kinematics = this.xyzacKinematics?.snapshot?.();
|
||||
const kinematicsJoints = kinematics?.joints?.length === 5 ? [...kinematics.joints] : null;
|
||||
const unsigned = {
|
||||
schema: "sim_motion.v1",
|
||||
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]),
|
||||
velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0,
|
||||
feed_override: 1,
|
||||
spindle_override: 1,
|
||||
homed: Boolean(home.state?.homed),
|
||||
homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(home.state?.homed && index < (home.state?.joint_count ?? 0))),
|
||||
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) },
|
||||
on_any_limit: Boolean(home.state?.limit_error),
|
||||
following_error: 0,
|
||||
jog: null,
|
||||
estop: Boolean(ioState?.estop),
|
||||
fault: ioState?.fault ? (ioState.fault_reason ?? "io_fault") : null,
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
motionProjection() {
|
||||
if (this.xyzacProgram) return this.wasmMotionProjection();
|
||||
if (this.motionAuthority !== "wasm") return this.motion.export();
|
||||
const wasm = this.wasmMotion.read();
|
||||
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position } } : this.motion.export();
|
||||
}
|
||||
|
||||
wasmDeviceProjection({ includeBytes = false } = {}) {
|
||||
if (!this.xyzacIo) return this.devices.export();
|
||||
const state = this.xyzacIo.canonical();
|
||||
if (!state || state.status === "blocked") return { schema: "wasm_xyzac_devices.v1", source: "wasm_io", status: "blocked" };
|
||||
const ioExport = includeBytes ? this.xyzacIo.export() : null;
|
||||
const unsigned = { schema: "wasm_xyzac_devices.v1", source: "wasm_io", ...state, ...(includeBytes ? { state_bytes: ioExport.state_bytes } : {}) };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
loadXyzacSubroutines(programPath) {
|
||||
if (!this.xyzacProgram) return { status: "verified", count: 0 };
|
||||
const requirement = this.xyzacProgram.requiresSubroutines();
|
||||
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"]];
|
||||
for (const [suffix, name] of rows) {
|
||||
const path = `${basePath.replace(/\/demos$/, "")}/${suffix}`;
|
||||
const source = this.workspaceFiles[path];
|
||||
if (typeof source !== "string") return { status: "error", code: "program_subroutine_missing", path };
|
||||
const added = this.xyzacProgram.addSubroutine(name, source);
|
||||
if (added.status !== "verified") return added;
|
||||
}
|
||||
return { status: "verified", count: rows.length };
|
||||
}
|
||||
|
||||
panelSnapshot({ task = this.task.export(), program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick, motionTrace = null, includeToolpath = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeConfiguration = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeStableRuntime = input.kind !== "SIM_STEP" || !this.xyzacProgram } = {}) {
|
||||
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
const homeLimit = this.wasmHomeLimit.read();
|
||||
const devices = this.devices.export();
|
||||
const deviceIo = this.deviceIo.export();
|
||||
const halRuntime = this.hal.export();
|
||||
const authoritative = this.authoritative.export();
|
||||
const devices = this.wasmDeviceProjection();
|
||||
const deviceIo = this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export();
|
||||
const halRuntime = this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export();
|
||||
const authoritative = this.xyzacProgram
|
||||
? { 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 ?? {}, action: input.kind, payload: input.payload ?? {} });
|
||||
const gateInput = { task, program: program ?? {}, motion, devices, taskfile: Boolean(this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||
const gateActions = ["load", "run", "step", "pause", "resume", "abort", "restart", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "reset", "spindle", "coolant"];
|
||||
const gates = Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)]));
|
||||
const position = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
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 gateInput = { task, program: program ?? {}, motion, devices, 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 gates = { ...Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)])), ...xyzacGateMatrix(gateInput) };
|
||||
const wasmPosition = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? wasmMotion.state.position
|
||||
: authoritative.kinematics.pose;
|
||||
// Generic WASM-compatible cases expose their historical XYZ panel shape;
|
||||
// the selected XYZAC case is the only panel that publishes all nine pose
|
||||
// coordinates and the five inverse joints.
|
||||
const position = this.xyzacProgram
|
||||
? wasmPosition
|
||||
: { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
|
||||
const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints;
|
||||
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? [position.x, position.y, position.z]
|
||||
? (inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0])
|
||||
: authoritative.kinematics.joints;
|
||||
const preview = this.previewState?.status === "verified" ? this.previewState : null;
|
||||
const visual = {
|
||||
schema: "axis-visual.v1",
|
||||
pose: { x: position.x, y: position.y, z: position.z },
|
||||
pose: { ...position },
|
||||
joints: Array.isArray(joints) ? [...joints] : [],
|
||||
toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })),
|
||||
...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}),
|
||||
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
|
||||
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
|
||||
...(Array.isArray(motionTrace) && motionTrace.length ? { motion_trace: motionTrace } : {}),
|
||||
source: "wasm_canonical_motion",
|
||||
};
|
||||
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
|
||||
const unsigned = {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
context_id: this.contextId,
|
||||
@@ -171,20 +264,20 @@ export class SimSession {
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
coordinates: { machine: [position.x, position.y, position.z], relative: [position.x, position.y, position.z], commanded: [position.x, position.y, position.z] },
|
||||
coordinates: { machine: [...axisCoordinates], relative: [...axisCoordinates], commanded: [...axisCoordinates] },
|
||||
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
||||
wasm_motion: wasmMotion,
|
||||
home_limit: homeLimit,
|
||||
hal: { configuration: this.halState, runtime: halRuntime },
|
||||
devices,
|
||||
device_io: deviceIo,
|
||||
configuration: this.configurationState,
|
||||
...(includeStableRuntime ? { home_limit: homeLimit, hal: { configuration: this.halState, runtime: halRuntime }, devices, device_io: deviceIo } : {}),
|
||||
...(includeConfiguration ? { configuration: this.configurationState } : {}),
|
||||
axis_state: axis,
|
||||
gates,
|
||||
visual,
|
||||
authoritative,
|
||||
motion_authority: this.motionAuthority,
|
||||
task_authority: this.task.authority(),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
...(includeStableRuntime && this.xyzacIo ? { io: this.xyzacIo.export() } : {}),
|
||||
...(xyzac ? { xyzac } : {}),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
@@ -211,18 +304,24 @@ export class SimSession {
|
||||
}
|
||||
|
||||
commandGate(input) {
|
||||
const task = this.task.export();
|
||||
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : {};
|
||||
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
|
||||
const devices = this.wasmDeviceProjection();
|
||||
if (input.kind === "KINEMATICS_SELECT" || input.kind === "VISUAL_CLEAR") {
|
||||
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 });
|
||||
}
|
||||
const action = PANEL_GATE_ACTIONS[input.kind];
|
||||
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
|
||||
const task = this.task.export();
|
||||
const program = this.program.loaded ? this.program.snapshot() : {};
|
||||
const motion = this.motion.export();
|
||||
const devices = this.devices.export();
|
||||
return axisGate(action, {
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
devices,
|
||||
taskfile: Boolean(this.program.loaded),
|
||||
taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().loaded : this.program.loaded),
|
||||
kinematicsIdentity: this.motionAuthority === "wasm",
|
||||
});
|
||||
}
|
||||
@@ -234,11 +333,11 @@ export class SimSession {
|
||||
tick: this.tick,
|
||||
created: this.created,
|
||||
protocol_seq: this.timeline.events.cursor,
|
||||
devices: this.devices.export(),
|
||||
motion: this.motion.export(),
|
||||
devices: this.wasmDeviceProjection({ includeBytes: true }),
|
||||
motion: this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export(),
|
||||
plc: this.plc.export(),
|
||||
hal_runtime: this.hal.export(),
|
||||
device_io: this.deviceIo.export(),
|
||||
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(),
|
||||
device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(),
|
||||
configuration_batch: this.configurationBatch.export(),
|
||||
io_ack: this.ioAck.export(),
|
||||
callbacks: this.callbacks.export(),
|
||||
@@ -249,13 +348,15 @@ export class SimSession {
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
preview: this.previewState,
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
|
||||
ledger: this.ledger.export(),
|
||||
};
|
||||
if (this.program.loaded) state.program = this.program.exportState();
|
||||
if (this.xyzacProgram) state.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
|
||||
else if (this.program.loaded) state.program = this.program.exportState();
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -265,11 +366,11 @@ export class SimSession {
|
||||
session_id: this.sessionId,
|
||||
configuration: this.configurationState,
|
||||
hal: this.halState,
|
||||
devices: this.devices.export(),
|
||||
motion: this.motion.export(),
|
||||
devices: this.wasmDeviceProjection(),
|
||||
motion: this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export(),
|
||||
plc: this.plc.export(),
|
||||
hal_runtime: this.hal.export(),
|
||||
device_io: this.deviceIo.export(),
|
||||
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(),
|
||||
device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(),
|
||||
configuration_batch: this.configurationBatch.export(),
|
||||
io_ack: this.ioAck.export(),
|
||||
callbacks: this.callbacks.export(),
|
||||
@@ -278,13 +379,15 @@ export class SimSession {
|
||||
event_cursor: this.timeline.events.cursor,
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
};
|
||||
if (this.program.loaded) value.program = this.program.exportState();
|
||||
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
|
||||
else if (this.program.loaded) value.program = this.program.exportState();
|
||||
return { ...value, state_hash: fnv1a64(value) };
|
||||
}
|
||||
|
||||
@@ -325,6 +428,9 @@ export class SimSession {
|
||||
if (trace.state?.motion_authority) this.motionAuthority = trace.state.motion_authority;
|
||||
if (trace.state?.wasm_motion) this.wasmMotion.restoreState(trace.state.wasm_motion);
|
||||
if (trace.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(trace.state.wasm_home_limit);
|
||||
if (trace.state?.device_io?.schema === "wasm_xyzac_device_io.v1" && this.xyzacDeviceIo) this.xyzacDeviceIo.restoreState(trace.state.device_io);
|
||||
if (trace.state?.io?.schema === "wasm_xyzac_io.v1" && this.xyzacIo) this.xyzacIo.restoreState(trace.state.io);
|
||||
if (trace.state?.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(trace.state.kinematics);
|
||||
if (trace.state?.motion) this.motion.restore(trace.state.motion);
|
||||
if (trace.state?.program) {
|
||||
this.program = new ProgramRunner(this.core);
|
||||
@@ -346,10 +452,62 @@ export class SimSession {
|
||||
}
|
||||
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
||||
try {
|
||||
validateCheckpoint(checkpoint, { contextId: this.contextId });
|
||||
} catch (error) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
|
||||
}
|
||||
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
|
||||
}
|
||||
if (this.xyzacProgram && checkpoint?.state?.program?.source_text) {
|
||||
try {
|
||||
const loaded = this.xyzacProgram.load(checkpoint.state.program.source_text, checkpoint.state.program.path ?? null);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
const subroutines = this.loadXyzacSubroutines(checkpoint.state.program.path ?? null);
|
||||
if (subroutines.status !== "verified") throw new Error(subroutines.code);
|
||||
const targetLine = Number.isSafeInteger(checkpoint.state.program.current_line) ? checkpoint.state.program.current_line : 0;
|
||||
const seek = this.xyzacProgram.seek(targetLine);
|
||||
if (seek.status !== "verified") throw new Error(seek.code);
|
||||
if (checkpoint.state.program) this.xyzacProgram.restoreState(checkpoint.state.program);
|
||||
if (checkpoint.state.wasm_motion) this.wasmMotion.restoreState(checkpoint.state.wasm_motion);
|
||||
if (checkpoint.state.wasm_home_limit) this.wasmHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
|
||||
let restoredWasmDeviceIo = false;
|
||||
if (checkpoint.state.device_io?.schema === "wasm_xyzac_device_io.v1" && this.xyzacDeviceIo) { this.xyzacDeviceIo.restoreState(checkpoint.state.device_io); restoredWasmDeviceIo = true; }
|
||||
if (checkpoint.state.io?.schema === "wasm_xyzac_io.v1" && this.xyzacIo) this.xyzacIo.restoreState(checkpoint.state.io);
|
||||
if (checkpoint.state.devices?.schema === "wasm_xyzac_devices.v1" && this.xyzacIo) this.xyzacIo.restoreState(checkpoint.state.devices);
|
||||
if (checkpoint.state.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(checkpoint.state.kinematics);
|
||||
if (checkpoint.state.task) this.task.restore(checkpoint.state.task);
|
||||
if (checkpoint.state.scheduler) this.scheduler.restore(checkpoint.state.scheduler);
|
||||
if (checkpoint.state.devices?.schema !== "wasm_xyzac_devices.v1") this.devices = new VirtualDevices({ state: checkpoint.state.devices });
|
||||
this.plc = new PlcRuntime(); if (checkpoint.state.plc) this.plc.restore(checkpoint.state.plc);
|
||||
this.hal = new HalRuntime(); if (checkpoint.state.hal_runtime?.schema === "sim_hal_runtime.v1") this.hal.restore(checkpoint.state.hal_runtime);
|
||||
this.configurationBatch = new ConfigurationBatchRuntime({ context_id: this.contextId }); if (checkpoint.state.configuration_batch) this.configurationBatch.restore(checkpoint.state.configuration_batch);
|
||||
this.ioAck = new IoAckTracker(); if (checkpoint.state.io_ack) this.ioAck.restore(checkpoint.state.io_ack);
|
||||
this.callbacks = new PythonCallbacks(); if (checkpoint.state.callbacks) this.callbacks.restore(checkpoint.state.callbacks);
|
||||
this.configurationState = checkpoint.state.configuration ?? this.configurationState;
|
||||
this.halState = checkpoint.state.hal ?? this.halState;
|
||||
this.previewState = checkpoint.state.preview ?? this.previewState;
|
||||
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
|
||||
this.timeline = new ReplayTimeline(checkpoint.timeline);
|
||||
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
|
||||
if (!restoredWasmDeviceIo && checkpoint.state.device_io?.schema === "wasm_xyzac_device_io.v1" && this.xyzacDeviceIo) this.xyzacDeviceIo.command("IO_STEP", { ticks: checkpoint.state.device_io.state?.tick ?? 0 });
|
||||
const currentOperations = [...this.operations.values()];
|
||||
this.operations = new Map([...checkpoint.operations ?? [], ...currentOperations].map((operation) => [operation.operation_id, operation]));
|
||||
const checkpointEntries = checkpoint.operations ?? [];
|
||||
const currentEntries = this.ledger.export().entries.filter((entry) => !checkpointEntries.some((operation) => operation.operation_id === entry.operation_id));
|
||||
this.ledger = new this.ledger.constructor();
|
||||
for (const operation of [...checkpointEntries, ...currentEntries]) {
|
||||
const entry = { ...operation, phase: operation.phase ?? (operation.status === "verified" ? "committed" : operation.status), side_effect_count: operation.side_effect_count ?? (operation.status === "verified" ? 1 : 0) };
|
||||
try { this.ledger.importEntry(entry); } catch { /* checkpoint operations remain available for diagnostics */ }
|
||||
}
|
||||
this.tick = checkpoint.state.tick ?? this.tick;
|
||||
return { schema: "simulation-recovery.v1", status: "recovered", context_id: this.contextId, checkpoint_hash: checkpoint.checkpoint_hash ?? null, simulation_tick: this.tick, state: this.stateForCheckpoint(), operations: checkpoint.operations ?? [] };
|
||||
} catch (error) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ code: "wasm_program_recovery", reason: String(error?.message ?? error) }] };
|
||||
}
|
||||
}
|
||||
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
|
||||
const replayMotion = new MotionState();
|
||||
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
|
||||
@@ -546,6 +704,7 @@ export class SimSession {
|
||||
this.motionAuthority = result.state?.motion_authority ?? checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_motion) this.wasmMotion.restoreState(result.state.wasm_motion);
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(result.state.wasm_home_limit);
|
||||
if (result.state?.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(result.state.kinematics);
|
||||
if (result.state?.task) this.task.restore(result.state.task);
|
||||
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
@@ -556,7 +715,9 @@ export class SimSession {
|
||||
if (this.motionAuthority !== "wasm") this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
if (wasmMotion.status !== "verified" || !result.state?.motion || fnv1a64(result.state.motion.axes) !== fnv1a64(wasmMotion.state.position)) throw new Error("wasm_motion_recovery_projection_mismatch");
|
||||
const motionAxes = result.state?.motion?.axes;
|
||||
const projectionMatches = motionAxes && Object.entries(motionAxes).every(([axis, value]) => Number.isFinite(value) && Math.abs((wasmMotion.state.position[axis] ?? Number.NaN) - value) <= 1e-12);
|
||||
if (wasmMotion.status !== "verified" || !projectionMatches) throw new Error("wasm_motion_recovery_projection_mismatch");
|
||||
}
|
||||
this.plc = new PlcRuntime();
|
||||
if (result.state?.plc) this.plc.restore(result.state.plc);
|
||||
@@ -661,8 +822,9 @@ export class SimSession {
|
||||
}
|
||||
const gate = this.commandGate(input);
|
||||
const handlerOwnsFault = (input.kind === "JOG_START" || input.kind === "JOG_STOP") && Boolean(this.motion.state.fault);
|
||||
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && this.program.status === "completed";
|
||||
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion) {
|
||||
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && (this.xyzacProgram ? this.xyzacProgram.snapshot().status === 4 : this.program.status === "completed");
|
||||
const abortAfterCompletion = input.kind === "PROGRAM_ABORT" && (this.xyzacProgram ? this.xyzacProgram.snapshot().status === 4 : this.program.status === "completed");
|
||||
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion && !abortAfterCompletion) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
const rejectedPayload = { command: input.kind, gate_reason: gate.reason, payload: input.payload ?? {} };
|
||||
try {
|
||||
@@ -713,7 +875,16 @@ export class SimSession {
|
||||
};
|
||||
|
||||
const deviceResponse = (kind, payload) => {
|
||||
const result = this.devices.command(kind, payload ?? {});
|
||||
const result = this.xyzacIo ? this.xyzacIo.command(kind, payload ?? {}) : this.devices.command(kind, payload ?? {});
|
||||
if (result.status === "verified" && this.xyzacProgram && kind === "ESTOP" && payload?.active === true) {
|
||||
// The WASM IO edge owns emergency stop. Stop the WASM trajectory and
|
||||
// pause the canonical program so no compatibility state can continue
|
||||
// feeding motion while the machine is stopped.
|
||||
const programState = this.xyzacProgram.snapshot();
|
||||
if (programState.status === 2) this.xyzacProgram.pause();
|
||||
this.wasmMotion.abort();
|
||||
this.wasmMotion.setPaused(true);
|
||||
}
|
||||
try {
|
||||
if (kind === "ESTOP" && payload?.active === true) this.task.estop(-904);
|
||||
else if (kind === "IO_RECOVER" && result.status === "verified") this.task.recover();
|
||||
@@ -721,15 +892,17 @@ export class SimSession {
|
||||
} catch (error) {
|
||||
return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed");
|
||||
}
|
||||
if (kind === "ESTOP") this.deviceIo.estop(payload?.active === true);
|
||||
if (kind === "IO_RECOVER" && result.status === "verified") this.deviceIo.interlockReset();
|
||||
if (!this.xyzacIo && kind === "ESTOP") this.deviceIo.estop(payload?.active === true);
|
||||
if (!this.xyzacIo && kind === "IO_RECOVER" && result.status === "verified") this.deviceIo.interlockReset();
|
||||
if (this.xyzacDeviceIo && kind === "ESTOP") this.xyzacDeviceIo.command("DEVICE_ESTOP", payload, input.operation_id);
|
||||
if (this.xyzacDeviceIo && kind === "IO_RECOVER" && result.status === "verified") this.xyzacDeviceIo.command("INTERLOCK_RESET", payload, input.operation_id);
|
||||
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
|
||||
return finish(this.envelope(responseKind, input, {
|
||||
...result,
|
||||
command: kind,
|
||||
task: this.task.export(),
|
||||
device_io: this.deviceIo.export(),
|
||||
motion: this.motion.export(),
|
||||
device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(),
|
||||
motion: this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export(),
|
||||
operation_id: input.operation_id ?? null,
|
||||
simulation_tick: this.tick,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -737,7 +910,8 @@ export class SimSession {
|
||||
|
||||
const deviceIoResponse = (kind, payload = {}) => {
|
||||
let result;
|
||||
if (kind === "ENCODER_SET") result = this.deviceIo.encoderSet(payload.channel, payload);
|
||||
if (this.xyzacDeviceIo) result = this.xyzacDeviceIo.command(kind, payload, input.operation_id);
|
||||
else if (kind === "ENCODER_SET") result = this.deviceIo.encoderSet(payload.channel, payload);
|
||||
else if (kind === "COOLANT_COMMAND") result = this.deviceIo.coolantCommand(payload);
|
||||
else if (kind === "COOLANT_FAULT") result = this.deviceIo.coolantFault(payload.fault === true);
|
||||
else if (kind === "DOOR_SET") result = this.deviceIo.doorSet(payload.open === true);
|
||||
@@ -749,7 +923,14 @@ export class SimSession {
|
||||
else if (kind === "DEVICE_ACK") result = this.deviceIo.ack(payload.operation_id);
|
||||
else result = this.deviceIo.fail("device_io_command_unsupported");
|
||||
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
|
||||
return finish(this.envelope(responseKind, input, { ...result, command: kind, device_io: this.deviceIo.export(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(responseKind, input, { ...result, command: kind, device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
};
|
||||
|
||||
const visualClearResponse = () => {
|
||||
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||
this.previewState = null;
|
||||
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
|
||||
};
|
||||
|
||||
switch (input.kind) {
|
||||
@@ -769,12 +950,21 @@ export class SimSession {
|
||||
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "POWER": {
|
||||
if (input.payload?.active !== true) return finish(this.error(input, "power_state", "power_active_required"), "failed");
|
||||
const result = this.xyzacIo ? this.xyzacIo.command("IO_RECOVER", input.payload ?? {}) : this.devices.command("IO_RECOVER", input.payload ?? {});
|
||||
if (result.status === "verified") {
|
||||
try { this.task.recover(); } catch { /* READY is already powered for the selected simulation. */ }
|
||||
if (this.xyzacDeviceIo) this.xyzacDeviceIo.command("INTERLOCK_RESET", input.payload ?? {}, input.operation_id);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, active: true, task: this.task.export(), device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "RESET": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
|
||||
const result = this.motion.resetFault();
|
||||
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
|
||||
if (this.task.state.state === "FAULT" || this.task.state.state === "STOPPED") this.task.recover();
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motion.export(), operation_id: input.operation_id ?? null }));
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motionProjection(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "LOAD_WORKSPACE": {
|
||||
const rootPath = input.payload?.root_path;
|
||||
@@ -782,6 +972,7 @@ export class SimSession {
|
||||
let startupLoaded = false;
|
||||
const configuration = input.payload?.configuration ?? null;
|
||||
if (configuration) {
|
||||
this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) };
|
||||
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
|
||||
if (startupWorkspace) {
|
||||
const startup = new ConfigurationStartupStateMachine({
|
||||
@@ -826,10 +1017,29 @@ export class SimSession {
|
||||
};
|
||||
}
|
||||
}
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") ? "wasm" : "js";
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || configuration?.kinematics?.type === "xyzac-trt" ? "wasm" : "js";
|
||||
if (configuration?.kinematics?.type === "xyzac-trt") {
|
||||
this.xyzacKinematics?.destroy();
|
||||
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, limits: configuration.home_limits });
|
||||
this.xyzacIo?.destroy();
|
||||
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: 2, toolCapacity: 1001 });
|
||||
this.xyzacDeviceIo?.destroy();
|
||||
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: 2 });
|
||||
this.xyzacProgram?.destroy();
|
||||
this.xyzacProgram = new WasmProgramRuntime(this.core);
|
||||
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
|
||||
this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35);
|
||||
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
|
||||
}
|
||||
if (configuration?.home_limits && this.motionAuthority === "wasm") {
|
||||
this.wasmHomeLimit.destroy();
|
||||
this.wasmHomeLimit = new WasmHomeLimit(this.core, { limits: configuration.home_limits });
|
||||
this.wasmHomeLimit = new WasmHomeLimit(this.core, {
|
||||
jointCount: configuration.home_limits.joint_count ?? 3,
|
||||
kinematicsType: configuration.home_limits.kinematics_type ?? 1,
|
||||
coordinates: configuration.home_limits.coordinates ?? "XYZ",
|
||||
offsets: configuration.kinematics?.offsets ?? {},
|
||||
limits: configuration.home_limits,
|
||||
});
|
||||
}
|
||||
if (input.payload?.hal) {
|
||||
const preflight = preflightHalConfiguration(input.payload.hal);
|
||||
@@ -849,7 +1059,7 @@ export class SimSession {
|
||||
};
|
||||
}
|
||||
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
|
||||
if (configuredKinematics) {
|
||||
if (configuredKinematics && !(this.motionAuthority === "wasm" && configuredKinematics === "xyzac-trt")) {
|
||||
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");
|
||||
}
|
||||
@@ -868,9 +1078,46 @@ export class SimSession {
|
||||
const limit = validateLimit("max_tick_step", cycles);
|
||||
if (limit.status !== "accepted") return finish(this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false }), "failed");
|
||||
let program_steps = [];
|
||||
const motion_trace = [];
|
||||
try {
|
||||
this.scheduler.step(cycles, {
|
||||
task: () => {
|
||||
if (this.xyzacProgram) {
|
||||
const wasmState = this.xyzacProgram.snapshot();
|
||||
if (![2, 3].includes(wasmState.status)) return;
|
||||
if (this.pendingProgramControl !== 0) {
|
||||
if (this.wasmMotion.read().state.queue_depth !== 0) return;
|
||||
const current = this.wasmMotion.read().state.position;
|
||||
this.xyzacProgramKinematicsMode = { 428: XYZAC_MODES.xyzac, 429: XYZAC_MODES.identity, 430: XYZAC_MODES.userk }[this.pendingProgramControl];
|
||||
const selected = this.xyzacKinematics.select(this.xyzacProgramKinematicsMode, [current.x, current.y, current.z, current.a ?? 0, current.c ?? 0]);
|
||||
if (selected.status !== "verified") throw new Error(`wasm_switchkins_control_${this.pendingProgramControl}`);
|
||||
this.pendingProgramControl = 0;
|
||||
return;
|
||||
}
|
||||
const step = this.xyzacProgram.step();
|
||||
if (step.status !== "verified") throw new Error(step.code);
|
||||
if (step.has_control && [428, 429, 430].includes(step.control_code)) {
|
||||
this.pendingProgramControl = step.control_code;
|
||||
if (this.wasmMotion.read().state.queue_depth === 0) {
|
||||
const current = this.wasmMotion.read().state.position;
|
||||
this.xyzacProgramKinematicsMode = { 428: XYZAC_MODES.xyzac, 429: XYZAC_MODES.identity, 430: XYZAC_MODES.userk }[step.control_code];
|
||||
const selected = this.xyzacKinematics.select(this.xyzacProgramKinematicsMode, [current.x, current.y, current.z, current.a ?? 0, current.c ?? 0]);
|
||||
if (selected.status !== "verified") throw new Error(`wasm_switchkins_control_${step.control_code}`);
|
||||
this.pendingProgramControl = 0;
|
||||
}
|
||||
}
|
||||
const steps = step.motion ? [step] : [];
|
||||
program_steps.push(step);
|
||||
for (const item of steps) {
|
||||
item.motion.kinematics_mode = this.xyzacProgramKinematicsMode;
|
||||
const linearNoop = item.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => item.motion.start[axis] === item.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = this.wasmMotion.enqueue(item.motion);
|
||||
if (enqueue.status === "error") throw new Error(enqueue.code);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const steps = this.program.tick(1);
|
||||
program_steps.push(...steps);
|
||||
for (const step of steps) {
|
||||
@@ -900,24 +1147,49 @@ export class SimSession {
|
||||
if (stepResult !== 0) throw new Error(`core_sim_step_${stepResult}`);
|
||||
this.tick = nextTick;
|
||||
this.task.setTick(nextTick);
|
||||
const motionState = this.motion.step(nextTick);
|
||||
const xyzacAuthority = Boolean(this.xyzacProgram);
|
||||
const motionState = xyzacAuthority ? null : this.motion.step(nextTick);
|
||||
const wasmMotion = this.wasmMotion.step();
|
||||
if (wasmMotion.status === "error" && wasmMotion.code !== "wasm_motion_step_-605") throw new Error(wasmMotion.code);
|
||||
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
if (motionState?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
if (wasmMotion.status === "verified") {
|
||||
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
|
||||
if (position.status === "error") throw new Error(position.code);
|
||||
if (!xyzacAuthority) {
|
||||
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
|
||||
if (position.status === "error") throw new Error(position.code);
|
||||
} else if (this.xyzacKinematics && wasmMotion.state.active_id !== 0 && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) {
|
||||
const inverse = this.xyzacKinematics.inverse(wasmMotion.state.position);
|
||||
if (inverse.status !== "verified") throw new Error(`wasm_xyzac_motion_inverse_${inverse.code ?? "error"}`);
|
||||
}
|
||||
}
|
||||
if (xyzacAuthority) {
|
||||
// An empty queue is a valid fixed-tick sample during an
|
||||
// M428/M429 synchronization boundary. Record it as a
|
||||
// stationary WASM sample so the display does not invent a
|
||||
// jump across the barrier.
|
||||
const traceState = wasmMotion.status === "verified" ? wasmMotion.state : this.wasmMotion.read().state;
|
||||
const traceKinematics = this.xyzacKinematics?.snapshot?.();
|
||||
motion_trace.push({
|
||||
tick: nextTick,
|
||||
pose: { ...traceState.position },
|
||||
joints: traceKinematics?.joints?.length === 5 ? [...traceKinematics.joints] : [],
|
||||
active_segment_id: traceState.active_id,
|
||||
active_progress: traceState.progress,
|
||||
});
|
||||
}
|
||||
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed && !motionState.fault) {
|
||||
const homeCheck = this.wasmHomeLimit.check(this.motion.state.joints);
|
||||
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed) {
|
||||
const homeCheck = this.wasmHomeLimit.check(this.xyzacProgram ? this.wasmMotionProjection().joints : this.motion.state.joints);
|
||||
if (homeCheck.status === "error") throw new Error(homeCheck.code);
|
||||
}
|
||||
const halState = this.hal.step(nextTick);
|
||||
const halState = this.xyzacIo ? this.xyzacIo.halCommand({ action: "step", ticks: 1 }) : this.hal.step(nextTick);
|
||||
if (halState.status === "error") throw new Error(halState.code);
|
||||
const deviceIoState = this.deviceIo.step(nextTick);
|
||||
const deviceIoState = this.xyzacIo ? this.xyzacIo.command("IO_STEP", { ticks: 1 }) : this.deviceIo.step(nextTick);
|
||||
if (deviceIoState.status === "blocked") throw new Error(deviceIoState.code);
|
||||
if (this.xyzacDeviceIo) {
|
||||
const deviceStep = this.xyzacDeviceIo.command("IO_STEP", { ticks: 1 });
|
||||
if (deviceStep.status === "blocked") throw new Error(deviceStep.code);
|
||||
}
|
||||
const plcState = this.plc.scan(nextTick);
|
||||
if (plcState.status === "error") throw new Error(plcState.code);
|
||||
const ackState = this.ioAck.step(nextTick);
|
||||
@@ -932,20 +1204,38 @@ export class SimSession {
|
||||
} catch (error) {
|
||||
return finish(this.error(input, "scheduler", String(error?.message ?? error)), "failed");
|
||||
}
|
||||
const program = this.program.loaded ? this.program.snapshot() : null;
|
||||
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null;
|
||||
if (program) {
|
||||
const status = program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status;
|
||||
this.task.setProgram(status, program.line);
|
||||
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
|
||||
this.task.setProgram(status, this.xyzacProgram ? program.current_line : program.line);
|
||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||
}
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
if (program) snapshotState.program = program;
|
||||
const snapshot = this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
|
||||
const lightweightXyzac = Boolean(this.xyzacProgram);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, {
|
||||
status: "verified", cycles, program_steps,
|
||||
...(lightweightXyzac && cycles <= 32 && motion_trace.length ? { motion_trace } : {}),
|
||||
motion_authority: this.motionAuthority,
|
||||
task: this.task.export(), scheduler: this.scheduler.export(), authoritative: snapshotState.authoritative,
|
||||
wasm_motion: this.wasmMotion.read(), motion: snapshotState.motion,
|
||||
...(lightweightXyzac ? {} : { plc: this.plc.export(), hal_runtime: snapshotState.hal_runtime, device_io: snapshotState.device_io, configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), snapshot }),
|
||||
...(program ? { program } : {}), state_hash: snapshot.state_hash,
|
||||
}));
|
||||
}
|
||||
case "PROGRAM_LOAD": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
|
||||
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
|
||||
if (this.xyzacProgram) {
|
||||
const result = this.xyzacProgram.load(source, input.payload?.path ?? null);
|
||||
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
|
||||
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); }
|
||||
const preview = result.status === "verified" ? this.xyzacProgram.preview() : result;
|
||||
if (result.status === "verified" && preview.status === "verified") { this.previewState = preview; this.task.setProgram("loaded", 0); }
|
||||
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
|
||||
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
|
||||
}
|
||||
const result = this.program.load(source, input.payload?.path ?? null);
|
||||
if (result.status === "verified") {
|
||||
this.authoritative.reset();
|
||||
@@ -962,12 +1252,17 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
motion: this.motionProjection(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PROGRAM_RUN": {
|
||||
if (this.xyzacProgram) {
|
||||
const result = this.xyzacProgram.start();
|
||||
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); this.task.setProgram("running", this.xyzacProgram.snapshot().current_line); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program.start();
|
||||
if (result.status === "verified") {
|
||||
this.task.setMode("AUTO");
|
||||
@@ -981,13 +1276,22 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
motion: this.motionProjection(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PROGRAM_PAUSE": case "PROGRAM_RESUME": case "PROGRAM_ABORT": {
|
||||
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
|
||||
if (this.xyzacProgram) {
|
||||
const result = this.xyzacProgram[action]();
|
||||
if (result.status === "verified") {
|
||||
if (action === "pause") { call(this.core, "sim_protocol_pause"); this.wasmMotion.setPaused(true); this.task.transition("PAUSED"); this.task.setProgram("paused", this.xyzacProgram.snapshot().current_line); }
|
||||
else if (action === "resume") { call(this.core, "sim_protocol_resume"); this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); this.task.setProgram("running", this.xyzacProgram.snapshot().current_line); }
|
||||
else { this.wasmMotion.abort(); this.task.transition("STOPPED"); this.task.setProgram("aborted", this.xyzacProgram.snapshot().current_line); }
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program[action]();
|
||||
if (result.status === "verified") {
|
||||
if (action === "pause") {
|
||||
@@ -1008,12 +1312,19 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
motion: this.motionProjection(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PROGRAM_RESTART": {
|
||||
if (this.xyzacProgram) {
|
||||
const source = this.xyzacProgram.source;
|
||||
const result = this.xyzacProgram.load(source, this.xyzacProgram.path);
|
||||
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
|
||||
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(this.xyzacProgram.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); const preview = this.xyzacProgram.preview(); if (preview.status === "verified") this.previewState = preview; this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program.restart();
|
||||
if (result.status === "verified") {
|
||||
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
|
||||
@@ -1034,6 +1345,13 @@ export class SimSession {
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PROGRAM_STEP": {
|
||||
if (this.xyzacProgram) {
|
||||
const current = this.xyzacProgram.snapshot();
|
||||
if (current.status === 1) this.xyzacProgram.start();
|
||||
const result = this.xyzacProgram.step();
|
||||
if (result.status === "verified") { if (result.motion) this.wasmMotion.enqueue(result.motion); this.wasmMotion.setPaused(true); this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line); this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED"); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
if (this.task.state.state === "READY") {
|
||||
this.task.setMode("AUTO");
|
||||
this.task.transition("RUNNING");
|
||||
@@ -1059,12 +1377,17 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
motion: this.motionProjection(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PROGRAM_SEEK": {
|
||||
if (this.xyzacProgram) {
|
||||
const result = this.xyzacProgram.seek(input.payload?.line);
|
||||
if (result.status === "verified") { this.task.setProgram("paused", input.payload?.line ?? 0); if (this.task.state.state === "READY") this.task.transition("RUNNING"); if (this.task.state.state === "RUNNING") this.task.transition("PAUSED"); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program.seek(input.payload?.line);
|
||||
if (result.status === "verified") this.task.setProgram("paused", result.line);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
@@ -1077,7 +1400,28 @@ export class SimSession {
|
||||
case "MDI_EXECUTE": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "mdi_while_program_active"), "failed");
|
||||
if (this.task.state.state === "STOPPED") this.task.recover();
|
||||
const result = this.program.executeBlock(input.payload?.block ?? input.payload?.command);
|
||||
const block = input.payload?.block ?? input.payload?.command;
|
||||
if (this.xyzacProgram) {
|
||||
const result = this.xyzacProgram.executeBlock(block);
|
||||
if (result.status === "verified") {
|
||||
const controlCode = Number(result.program?.control_code ?? 0);
|
||||
if ([428, 429, 430].includes(controlCode) && this.xyzacKinematics) {
|
||||
const mode = { 428: XYZAC_MODES.xyzac, 429: XYZAC_MODES.identity, 430: XYZAC_MODES.userk }[controlCode];
|
||||
const selected = this.xyzacKinematics.select(mode, this.xyzacKinematics.snapshot().joints);
|
||||
if (selected.status !== "verified") return finish(this.error(input, "kinematics", selected.code ?? "xyzac_switchkins_failed"), "failed");
|
||||
result.kinematics = this.xyzacKinematics.snapshot();
|
||||
result.remap = { code: `M${controlCode}`, hal_pin: "motion.switchkins-type", hal_pin_vars: true, sync: "M66 E0 L0" };
|
||||
}
|
||||
this.task.setMode("MDI");
|
||||
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
|
||||
if (result.motion) {
|
||||
const enqueue = this.wasmMotion.enqueue(result.motion);
|
||||
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
||||
}
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
const result = this.program.executeBlock(block);
|
||||
if (result.status === "verified") { this.task.setMode("MDI"); this.task.setProgram("loaded", this.program.currentLine); }
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
@@ -1086,15 +1430,64 @@ export class SimSession {
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "KINEMATICS_SELECT": {
|
||||
if (!this.xyzacKinematics) return finish(this.error(input, "kinematics", "xyzac_kinematics_unavailable"), "failed");
|
||||
const mode = Number(input.payload?.type);
|
||||
if (![XYZAC_MODES.identity, XYZAC_MODES.xyzac, XYZAC_MODES.userk].includes(mode)) return finish(this.error(input, "kinematics", "xyzac_kinematics_type_invalid"), "failed");
|
||||
const result = this.xyzacKinematics.select(mode, this.xyzacKinematics.snapshot().joints);
|
||||
const code = mode === XYZAC_MODES.xyzac ? "M428" : mode === XYZAC_MODES.identity ? "M429" : "M430";
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, remap: { code, hal_pin: "motion.switchkins-type", hal_pin_vars: true, sync: "M66 E0 L0" }, kinematics: this.xyzacKinematics.snapshot(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "VISUAL_CLEAR":
|
||||
return visualClearResponse();
|
||||
case "HOME": {
|
||||
if (this.xyzacProgram) {
|
||||
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
|
||||
const home = this.wasmHomeLimit.home(input.seq);
|
||||
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
||||
this.task.setMode("MANUAL");
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||
}
|
||||
const result = this.motion.home(input.payload?.axis ?? null);
|
||||
if (result.status === "verified" && this.motionAuthority === "wasm") {
|
||||
const home = this.wasmHomeLimit.home(input.seq);
|
||||
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
|
||||
if (this.motionAuthority === "wasm") {
|
||||
if (input.kind === "JOG_STOP") {
|
||||
this.wasmMotion.abort();
|
||||
this.wasmMotion.setPaused(true);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||
}
|
||||
if (input.kind === "SET_OVERRIDE") {
|
||||
const feed = Number(input.payload?.feed ?? input.payload?.feed_override);
|
||||
if (!Number.isFinite(feed) || feed < 0 || feed > 2) return finish(this.error(input, "motion_override", "motion_override_range"), "failed");
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, feed_override: feed, state: { ...this.motion.export(), feed_override: feed }, motion: this.motionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||
}
|
||||
if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed");
|
||||
const current = this.wasmMotion.read();
|
||||
if (current.status !== "verified") return finish(this.error(input, "motion_queue", "wasm_motion_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 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");
|
||||
this.wasmMotion.setPaused(false);
|
||||
const start = { ...current.state.position };
|
||||
const distance = Number(input.payload?.increment) > 0 ? Number(input.payload.increment) : 1;
|
||||
const target = Number(start[axis] ?? 0) + direction * distance;
|
||||
const configuredLimit = this.wasmHomeLimit.limits?.[axis];
|
||||
if (Array.isArray(configuredLimit) && configuredLimit.length === 2 && (target < Number(configuredLimit[0]) || target > Number(configuredLimit[1]))) return finish(this.error(input, "motion_queue", "motion_limit_range"), "failed");
|
||||
const end = { ...start, [axis]: target };
|
||||
const enqueue = this.wasmMotion.enqueue({ start, end, center: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 0, z: 1 }, velocity: Number(input.payload?.speed ?? 1), acceleration: 0, motion_type: 1, turn: 0, source_line: null });
|
||||
if (enqueue.status === "error") {
|
||||
const code = enqueue.code === "wasm_motion_enqueue_-602" ? "motion_limit_range" : enqueue.code === "wasm_motion_enqueue_-601" ? "motion_jog_nonfinite" : enqueue.code;
|
||||
return finish(this.error(input, "motion_queue", code), "failed");
|
||||
}
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_motion: this.wasmMotion.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||
}
|
||||
let result;
|
||||
if (input.kind === "JOG_START" && this.motionAuthority === "wasm" && !this.wasmHomeLimit.read().state.homed) return finish(this.error(input, "motion_state", "motion_unhomed"), "failed");
|
||||
if (input.kind === "JOG_START") result = this.motion.startJog(input.payload);
|
||||
@@ -1102,7 +1495,7 @@ export class SimSession {
|
||||
else if (input.kind === "SET_OVERRIDE") result = this.motion.setOverride(input.payload?.feed ?? input.payload?.feed_override, input.payload?.spindle ?? input.payload?.spindle_override);
|
||||
else result = this.motion.setFollowingError(input.payload?.value ?? input.payload?.following_error);
|
||||
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
|
||||
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
|
||||
@@ -1111,6 +1504,7 @@ export class SimSession {
|
||||
case "PROBE_CONFIG": case "IO_RECOVER":
|
||||
return deviceResponse(input.kind, input.payload);
|
||||
case "LIMIT_SET": {
|
||||
if (this.xyzacProgram) return finish(this.error(input, "motion_limit", "wasm_limit_inputs_are_configuration_owned"), "failed");
|
||||
const result = this.motion.setLimit(input.payload?.axis, input.payload?.active === true);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
@@ -1125,7 +1519,11 @@ export class SimSession {
|
||||
case "HAL_COMMAND": case "HAL_SET": {
|
||||
const payload = input.payload ?? {};
|
||||
let result;
|
||||
if (input.kind === "HAL_SET") {
|
||||
if (this.xyzacIo && input.kind === "HAL_SET") {
|
||||
result = this.xyzacIo.halValue(payload.name, payload.value, payload.type === "bit" ? 1 : payload.type === "s32" ? 2 : payload.type === "u32" ? 3 : 4, payload.target === "param");
|
||||
} else if (this.xyzacIo) {
|
||||
result = this.xyzacIo.halCommand(payload);
|
||||
} else if (input.kind === "HAL_SET") {
|
||||
result = payload.target === "param" ? this.hal.setParam(payload.name, payload.value, payload.type) : this.hal.setPin(payload.name, payload.value, payload.type);
|
||||
} else if (payload.action === "configure") {
|
||||
result = this.hal.configure(payload);
|
||||
@@ -1142,7 +1540,7 @@ export class SimSession {
|
||||
} else {
|
||||
result = this.hal.fail("hal_command_unsupported");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), hal_runtime: this.hal.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "ENCODER_SET": case "COOLANT_COMMAND": case "COOLANT_FAULT": case "DOOR_SET": case "INTERLOCK_SET": case "INTERLOCK_RESET": case "DEVICE_ESTOP": case "DEVICE_ACK_ISSUE": case "DEVICE_ACK": case "DEVICE_ACK_ABORT":
|
||||
return deviceIoResponse(input.kind, input.payload);
|
||||
@@ -1162,6 +1560,10 @@ export class SimSession {
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, io_ack: this.ioAck.export(), device_io: this.deviceIo.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "REMAP_REGISTER": case "REMAP_CALL": case "VCP_REGISTER": case "VCP_UPDATE": case "FILTER_EXEC": {
|
||||
if (this.xyzacProgram && input.kind === "VCP_UPDATE" && ["type0-button", "type1-button", "type2-button"].includes(input.payload?.pin)) return finish(this.error(input, "pyvcp_switch_requires_mdi", "pyvcp_switch_requires_mdi"), "failed");
|
||||
if (this.xyzacProgram && input.kind === "VCP_UPDATE" && input.payload?.pin === "vismach-clear") {
|
||||
return visualClearResponse();
|
||||
}
|
||||
const result = input.kind === "REMAP_REGISTER"
|
||||
? this.callbacks.registerRemap(input.payload?.name, input.payload)
|
||||
: input.kind === "REMAP_CALL"
|
||||
@@ -1174,6 +1576,7 @@ export class SimSession {
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, callbacks: this.callbacks.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "ESTOP": {
|
||||
if (this.xyzacProgram) return deviceResponse(input.kind, input.payload);
|
||||
const motionResult = this.motion.estop(input.payload?.active === true);
|
||||
const deviceResult = deviceResponse(input.kind, input.payload);
|
||||
if (motionResult.status === "error") return finish(this.envelope("ERROR", input, { ...motionResult, task: this.task.export() }), "failed");
|
||||
@@ -1207,6 +1610,17 @@ export class SimSession {
|
||||
const result = this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
|
||||
return finish(this.envelope("RECOVERY_STATUS", input, result), result.status === "blocked" ? "failed" : "verified");
|
||||
}
|
||||
case "CANCEL": {
|
||||
const target = input.payload?.operation_id;
|
||||
let ledgerResult = null;
|
||||
if (text(target)) {
|
||||
try { ledgerResult = this.ledger.abort(target, input.payload?.reason ?? "cancel_requested"); }
|
||||
catch (error) { ledgerResult = { status: "missing", error: String(error?.message ?? error) }; }
|
||||
const operation = this.operations.get(target);
|
||||
if (operation && operation.status !== "verified") operation.status = "aborted";
|
||||
}
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: ledgerResult?.status ?? "missing", cancelled: ledgerResult?.status === "aborted", retry_allowed: false, operation_id: target ?? null, ledger: ledgerResult }));
|
||||
}
|
||||
case "ABORT": {
|
||||
const target = input.payload?.operation_id;
|
||||
const operation = text(target) ? this.operations.get(target) : null;
|
||||
@@ -1222,6 +1636,9 @@ export class SimSession {
|
||||
}
|
||||
case "DESTROY":
|
||||
this.program.destroy();
|
||||
this.xyzacIo?.destroy();
|
||||
this.xyzacDeviceIo?.destroy();
|
||||
this.xyzacKinematics?.destroy();
|
||||
this.task.destroy();
|
||||
this.scheduler.destroy();
|
||||
call(this.core, "sim_destroy");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession } from "./sim-session.mjs";
|
||||
import { XyzacSession } from "./xyzac-session.mjs";
|
||||
|
||||
const core = await createModule();
|
||||
const session = new SimSession(core);
|
||||
const session = new XyzacSession(core);
|
||||
|
||||
self.onmessage = (event) => {
|
||||
try {
|
||||
|
||||
@@ -49,7 +49,7 @@ const stepped = send("SIM_STEP", "jog-step", { cycles: 3 });
|
||||
assert.equal(stepped.payload.task_authority, "wasm_task_canonical");
|
||||
assert.equal(stepped.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
assert.ok(stepped.payload.wasm_home_limit.state.tick > home.payload.wasm_home_limit.state.tick);
|
||||
assert.deepEqual(stepped.payload.motion.axes, { x: stepped.payload.motion.joints[0], y: stepped.payload.motion.joints[1], z: stepped.payload.motion.joints[2] });
|
||||
assert.deepEqual({ x: stepped.payload.motion.axes.x, y: stepped.payload.motion.axes.y, z: stepped.payload.motion.axes.z }, { x: stepped.payload.motion.joints[0], y: stepped.payload.motion.joints[1], z: stepped.payload.motion.joints[2] });
|
||||
|
||||
const limit = send("LIMIT_SET", "limit", { axis: "x", active: true });
|
||||
assert.equal(limit.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
|
||||
65
web/worker/test_xyzac_exact_preview.mjs
Normal file
65
web/worker/test_xyzac_exact_preview.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const source = bundle.files?.[PROGRAM_PATH];
|
||||
assert.equal(typeof source, "string");
|
||||
assert.equal(source, await readFile(new URL(`../../${PROGRAM_PATH}`, import.meta.url), "utf8"));
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-exact-preview" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
const loaded = send("PROGRAM_LOAD", "program-load", { source, path: PROGRAM_PATH });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
assert.equal(loaded.payload.program.path, PROGRAM_PATH);
|
||||
assert.equal(loaded.payload.program.source_hash, fnv1a64(source));
|
||||
assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(loaded.payload.preview.segments.length, 29);
|
||||
assert.equal(loaded.payload.panel_snapshot.visual.source, "wasm_canonical_motion");
|
||||
assert.equal(loaded.payload.panel_snapshot.visual.toolpath.length, 29);
|
||||
const arcs = loaded.payload.preview.segments.filter((segment) => segment.motion_type === 2);
|
||||
const rapids = loaded.payload.preview.segments.filter((segment) => segment.kind === "rapid");
|
||||
assert.equal(arcs.length, 4);
|
||||
assert.equal(rapids.length, 25);
|
||||
assert.ok(rapids.every((segment) => segment.canonical_motion_mode === 0));
|
||||
assert.ok(arcs.every((segment) => segment.canonical_motion_mode === 2));
|
||||
assert.ok(rapids.every((segment) => Math.abs(segment.velocity - 35) < 1e-12));
|
||||
assert.ok(arcs.every((segment) => Math.abs(segment.velocity - (1000 / 60)) < 1e-12));
|
||||
assert.ok(arcs.every((segment) => segment.turn === -3));
|
||||
assert.ok(arcs.every((segment) => Array.isArray(segment.samples) && segment.samples.length === 97));
|
||||
assert.ok(arcs.every((segment) => segment.samples.some((sample) => sample.z < segment.start.z && sample.z > segment.end.z)));
|
||||
assert.ok(arcs.every((segment) => segment.samples_source === "linuxcnc-wasm-sim_posemath_segment_point"));
|
||||
assert.ok(arcs.every((segment) => segment.kind === "helix" && segment.helix === true && segment.radius === 10 && segment.turns === 3 && segment.axial_delta === -5));
|
||||
assert.ok(arcs.every((segment) => segment.start.a === 20 && segment.end.a === 20 && segment.start.c === 45 && segment.end.c === 45));
|
||||
assert.deepEqual(arcs.map((segment) => segment.source_line), [67, 67, 67, 67]);
|
||||
for (const segment of arcs) {
|
||||
const radius = segment.samples.map((sample) => Math.hypot(sample.x - segment.center.x, sample.y - segment.center.y));
|
||||
assert.ok(Math.max(...radius) - Math.min(...radius) < 1e-9);
|
||||
assert.ok(Math.abs(radius[0] - 10) < 1e-9);
|
||||
assert.ok(Math.abs(segment.samples[0].z - 10) < 1e-9);
|
||||
assert.ok(Math.abs(segment.samples.at(-1).z - 5) < 1e-9);
|
||||
}
|
||||
assert.deepEqual(arcs.map((segment) => [segment.end.x, segment.end.y, segment.end.z]), [[10, 20, 5], [-30, 20, 5], [-30, -20, 5], [10, -20, 5]]);
|
||||
const clear = send("VISUAL_CLEAR", "visual-clear");
|
||||
assert.equal(clear.payload.visual_only, true);
|
||||
assert.deepEqual(clear.payload.before, clear.payload.after);
|
||||
const restarted = send("PROGRAM_RESTART", "program-restart");
|
||||
assert.equal(restarted.payload.program.path, PROGRAM_PATH);
|
||||
assert.equal(restarted.payload.panel_snapshot.visual.toolpath.length, 29);
|
||||
console.log(JSON.stringify({ schema: "xyzac-exact-tool-preview.v1", status: "verified", case: CASE, program_path: PROGRAM_PATH, source_hash: loaded.payload.program.source_hash, segment_count: loaded.payload.preview.segments.length, rapid_count: rapids.length, arc_count: arcs.length, arc_turn: -3, motion_mode_source: "linuxcnc-wasm-sim_program_canonical", helix_validation: { sample_points_per_helix: arcs[0].samples.length, samples_source: arcs[0].samples_source, radius_mm: 10, z_start: 10, z_end: 5, turns_per_helix: 3 }, helix_details: arcs.map((segment) => ({ source_line: segment.source_line, kind: segment.kind, start: segment.start, end: segment.end, center: segment.center, radius: segment.radius, turns: segment.turns, axial_delta: segment.axial_delta, samples: segment.samples.length, samples_source: segment.samples_source })), visual_segments: loaded.payload.panel_snapshot.visual.toolpath.length, restart_visual_segments: restarted.payload.panel_snapshot.visual.toolpath.length, clear_visual_only: clear.payload.visual_only, state_hash: loaded.payload.panel_snapshot.state_hash }));
|
||||
57
web/worker/test_xyzac_gate_matrix.mjs
Normal file
57
web/worker/test_xyzac_gate_matrix.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
import { axisGate, xyzacGateMatrix } from "../app/axis-contract.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-gate-matrix" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
const initial = loaded.payload.panel_snapshot;
|
||||
assert.equal(initial.xyzac.coordinates, "XYZAC");
|
||||
assert.equal(initial.xyzac.joint_count, 5);
|
||||
assert.equal(initial.gates.run.enabled, false);
|
||||
assert.equal(initial.gates.run.reason, "axis_taskfile_missing");
|
||||
assert.equal(initial.gates.runLine.reason, "axis_taskfile_missing");
|
||||
assert.equal(initial.gates.switchkinsIdentity.enabled, true);
|
||||
assert.equal(initial.gates.switchkinsXyzac.enabled, true);
|
||||
assert.equal(initial.gates.switchkinsUserk.enabled, true);
|
||||
assert.equal(initial.gates.vismachClear.visual_only, true);
|
||||
const noFile = axisGate("runLine", { task: { state: "READY", mode: "MANUAL" }, program: {}, motion: {}, taskfile: false, highlight_line: null });
|
||||
assert.equal(noFile.enabled, false);
|
||||
assert.equal(noFile.reason, "axis_taskfile_missing");
|
||||
const highlightMissing = axisGate("runLine", { task: { state: "READY", mode: "MANUAL" }, program: { source_hash: "fnv1a64:file" }, motion: {}, taskfile: true, highlight_line: null });
|
||||
assert.equal(highlightMissing.reason, "axis_highlight_missing");
|
||||
const invalidSwitch = xyzacGateMatrix({ task: { state: "READY", mode: "MANUAL" }, program: {}, motion: {}, taskfile: false, xyzac: { coordinates: "XYZ", joint_count: 3, hal_pin_vars: false, switchkins_net: false, finite_offsets: true, userk_capability: false } });
|
||||
assert.equal(invalidSwitch.switchkinsXyzac.enabled, false);
|
||||
assert.equal(invalidSwitch.switchkinsXyzac.reason, "xyzac_hal_pin_vars_missing");
|
||||
send("PROGRAM_LOAD", "program-load", { path: "gate.ngc", source: "G21 G90\nG1 X10 A20 C30 F100\nM2\n" });
|
||||
const running = send("PROGRAM_RUN", "program-run");
|
||||
assert.equal(running.payload.panel_snapshot.gates.pause.enabled, true);
|
||||
assert.equal(running.payload.panel_snapshot.gates.abort.enabled, true);
|
||||
assert.equal(running.payload.panel_snapshot.gates.resume.enabled, false);
|
||||
assert.equal(running.payload.panel_snapshot.gates.switchkinsXyzac.enabled, false);
|
||||
send("PROGRAM_PAUSE", "pause");
|
||||
const paused = send("SNAPSHOT_REQUEST", "paused-snapshot");
|
||||
assert.equal(paused.payload.panel_snapshot.gates.resume.enabled, true);
|
||||
assert.equal(paused.payload.panel_snapshot.gates.pause.enabled, false);
|
||||
send("PROGRAM_ABORT", "abort");
|
||||
const estopGates = xyzacGateMatrix({ task: { state: "ESTOP", mode: "MANUAL" }, program: { source_hash: "fnv1a64:file" }, motion: {}, taskfile: true, xyzac: { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } });
|
||||
assert.equal(estopGates.switchkinsXyzac.enabled, false);
|
||||
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
|
||||
send("ESTOP", "estop-reset", { active: false });
|
||||
send("IO_RECOVER", "recover");
|
||||
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
|
||||
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");
|
||||
assert.equal(recovered.payload.panel_snapshot.gates.vismachClear.visual_only, true);
|
||||
console.log(JSON.stringify({ schema: "xyzac-gate-matrix-rtcp-03.v1", status: "verified", case: CASE, normal: ["runLine", "switchkinsIdentity", "switchkinsXyzac", "switchkinsUserk"], blocked: [noFile.reason, highlightMissing.reason, invalidSwitch.switchkinsXyzac.reason, estopGates.switchkinsXyzac.reason], running: { pause: true, abort: true }, paused: { resume: true }, estop: { switchkins_enabled: estopGates.switchkinsXyzac.enabled }, final_state_hash: recovered.payload.panel_snapshot.state_hash }));
|
||||
65
web/worker/test_xyzac_hal_tool_alarm.mjs
Normal file
65
web/worker/test_xyzac_hal_tool_alarm.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-hal-tool" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
|
||||
const xml = await readFile(new URL("../../linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.xml", import.meta.url), "utf8");
|
||||
const postgui = await readFile(new URL("../../linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/switchkins_postgui.hal", import.meta.url), "utf8");
|
||||
for (const pin of ["type0-button", "type1-button", "type2-button", "vismach-clear"]) assert.ok(xml.includes(pin));
|
||||
for (const edge of ["pyvcp.type0-button", "pyvcp.type1-button", "pyvcp.type2-button", "vismach.plotclear"]) assert.ok(postgui.includes(edge));
|
||||
for (const [code, type] of [["M429", 0], ["M428", 1], ["M430", 2]]) assert.equal(send("MDI_EXECUTE", `switch-${code}`, { block: code }).payload.kinematics.active_type, type);
|
||||
const directSwitch = send("VCP_UPDATE", "direct-switch", { name: "xyzac", pin: "type1-button", value: 1 });
|
||||
assert.equal(directSwitch.kind, "ERROR");
|
||||
assert.equal(directSwitch.payload.code, "pyvcp_switch_requires_mdi");
|
||||
send("SNAPSHOT_REQUEST", "before-clear");
|
||||
const clear = send("VCP_UPDATE", "visual-clear", { name: "xyzac", pin: "vismach-clear", value: 1 });
|
||||
assert.equal(clear.payload.visual_only, true);
|
||||
assert.deepEqual(clear.payload.before, clear.payload.after);
|
||||
|
||||
const allowed = send("HAL_SET", "hal-allowed", { target: "pin", name: "motion.probe-input", type: "bit", value: true });
|
||||
assert.equal(allowed.kind, "COMMAND_RESULT");
|
||||
const denied = send("HAL_SET", "hal-denied", { target: "pin", name: "motion.switchkins-type", type: "s32", value: 2 });
|
||||
assert.equal(denied.kind, "ERROR");
|
||||
assert.equal(denied.payload.code, "wasm_hal_write_denied");
|
||||
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 1200, enabled: true }).kind, "COMMAND_RESULT");
|
||||
send("IO_STEP", "spindle-step", { ticks: 10 });
|
||||
const spindle = send("SNAPSHOT_REQUEST", "spindle-snapshot").payload.panel_snapshot.io.state.spindles[0];
|
||||
assert.equal(spindle.command_rpm, 1200);
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-off", { spindle: 0, rpm: 0, enabled: false }).kind, "COMMAND_RESULT");
|
||||
send("IO_STEP", "spindle-stop-step", { ticks: 20 });
|
||||
assert.equal(send("COOLANT_COMMAND", "coolant-on", { mist: true, flood: true }).payload.panel_snapshot.device_io.source, "wasm_device_io");
|
||||
assert.equal(send("TOOL_PREPARE", "tool-prepare", { tool: 7, pocket: 3 }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("TOOL_PREPARED", "tool-prepared", { prepared: true }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("TOOL_CHANGE", "tool-change").kind, "COMMAND_RESULT");
|
||||
assert.equal(send("TOOL_CHANGED", "tool-changed", { changed: true }).payload.device_io.state.tool.current, 7);
|
||||
assert.equal(send("MDI_EXECUTE", "g43", { block: "G43 H7" }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("MDI_EXECUTE", "g49", { block: "G49" }).kind, "COMMAND_RESULT");
|
||||
|
||||
const ack = send("DEVICE_ACK_ISSUE", "ack-one", { timeout_ticks: 2 });
|
||||
assert.equal(ack.kind, "COMMAND_RESULT");
|
||||
assert.equal(send("DEVICE_ACK", "ack-confirm", { operation_id: "ack-one" }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("DEVICE_ACK_ISSUE", "ack-timeout", { timeout_ticks: 1 }).kind, "COMMAND_RESULT");
|
||||
send("SIM_STEP", "ack-timeout-step", { cycles: 1 });
|
||||
const lateAck = send("DEVICE_ACK", "ack-late", { operation_id: "ack-timeout" });
|
||||
assert.equal(lateAck.kind, "ERROR");
|
||||
assert.equal(lateAck.payload.code, "wasm_sim_device_ack_ack_-1008");
|
||||
assert.equal(send("DEVICE_ACK_ISSUE", "ack-abort", { timeout_ticks: 5 }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("DEVICE_ACK_ABORT", "ack-abort-command", { operation_id: "ack-abort" }).kind, "COMMAND_RESULT");
|
||||
const alarm = denied.payload;
|
||||
assert.equal(typeof alarm.operation_id, "string");
|
||||
assert.ok(session.timeline.export().events.events.some((event) => event.kind === "HAL_SET"));
|
||||
const final = send("SNAPSHOT_REQUEST", "final").payload.panel_snapshot;
|
||||
console.log(JSON.stringify({ schema: "xyzac-hal-tool-alarm-rtcp-07.v1", status: "verified", case: CASE, pyvcp: ["M429", "M428", "M430", "vismach-clear"], hal: { allowed: "motion.probe-input", denied: denied.payload.code }, tool: final.io.state.tool.current, spindle_rpm: spindle.command_rpm, coolant: final.device_io.state.coolant, ack: { normal: "verified", timeout: lateAck.payload.code, abort: "verified" }, alarm: { code: alarm.code, operation_id: alarm.operation_id }, state_hash: final.state_hash }));
|
||||
130
web/worker/test_xyzac_machine_sequence.mjs
Normal file
130
web/worker/test_xyzac_machine_sequence.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const source = bundle.files?.[PROGRAM_PATH];
|
||||
assert.equal(typeof source, "string");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "xyzac-machine-sequence" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `machine-${seq}`, payload });
|
||||
|
||||
send("HELLO");
|
||||
send("SESSION_ATTACH");
|
||||
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
const loaded = send("PROGRAM_LOAD", { source, path: PROGRAM_PATH });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
assert.equal(loaded.payload.panel_snapshot.devices.source, "wasm_io");
|
||||
const jsMotionHash = session.motion.export().state_hash;
|
||||
|
||||
const emergency = send("ESTOP", { active: true });
|
||||
assert.equal(emergency.kind, "COMMAND_RESULT");
|
||||
assert.equal(emergency.payload.task.state, "ESTOP");
|
||||
assert.equal(emergency.payload.motion_authority, "wasm");
|
||||
assert.equal(emergency.payload.device_io.source, "wasm_io");
|
||||
assert.equal(emergency.payload.device_io.state.estop, 1);
|
||||
assert.equal(emergency.payload.device_io.state.ready, 0);
|
||||
assert.equal(emergency.payload.panel_snapshot.motion.estop, true);
|
||||
assert.equal(emergency.payload.panel_snapshot.wasm_motion.state.queue_depth, 0);
|
||||
assert.equal(session.motion.export().state_hash, jsMotionHash);
|
||||
|
||||
const released = send("ESTOP", { active: false });
|
||||
assert.equal(released.payload.status, "verified");
|
||||
assert.equal(released.payload.device_io.state.estop, 0);
|
||||
assert.equal(released.payload.device_io.state.ready, 0);
|
||||
assert.equal(released.payload.task.state, "ESTOP");
|
||||
const recovered = send("IO_RECOVER");
|
||||
assert.equal(recovered.payload.task.state, "READY");
|
||||
assert.equal(recovered.payload.device_io.state.estop, 0);
|
||||
assert.equal(recovered.payload.device_io.state.ready, 1);
|
||||
const recoveryEpoch = recovered.payload.device_io.state.recovery_epoch;
|
||||
|
||||
const powered = send("POWER", { active: true });
|
||||
assert.equal(powered.payload.status, "verified");
|
||||
assert.equal(powered.payload.task.state, "READY");
|
||||
assert.equal(powered.payload.device_io.source, "wasm_io");
|
||||
assert.equal(powered.payload.device_io.state.ready, 1);
|
||||
assert.ok(powered.payload.device_io.state.recovery_epoch > recoveryEpoch);
|
||||
|
||||
const homed = send("HOME", { all: true });
|
||||
assert.equal(homed.payload.status, "verified");
|
||||
assert.equal(homed.payload.motion_authority, "wasm");
|
||||
assert.equal(homed.payload.wasm_home_limit.state.homed, true);
|
||||
assert.equal(homed.payload.wasm_home_limit.state.joint_count, 5);
|
||||
assert.deepEqual(homed.payload.panel_snapshot.motion.homed_axes, [true, true, true, true, true]);
|
||||
assert.deepEqual(homed.payload.wasm_home_limit.state.joints, [0, 0, 0, 0, 0]);
|
||||
|
||||
const running = send("PROGRAM_RUN");
|
||||
assert.equal(running.payload.status, "verified");
|
||||
assert.equal(running.payload.task.state, "RUNNING");
|
||||
assert.equal(running.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(running.payload.motion_authority, "wasm");
|
||||
assert.equal(session.wasmMotion.exportState().controls.paused, false);
|
||||
|
||||
const stepped = send("SIM_STEP", { cycles: 180 });
|
||||
assert.equal(stepped.payload.status, "verified");
|
||||
assert.equal(stepped.payload.motion_authority, "wasm");
|
||||
assert.ok(stepped.payload.program.current_line > 0);
|
||||
assert.ok(stepped.payload.program_steps.length > 0);
|
||||
assert.equal(stepped.payload.panel_snapshot.visual.source, "wasm_canonical_motion");
|
||||
assert.deepEqual(stepped.payload.panel_snapshot.visual.pose, stepped.payload.wasm_motion.state.position);
|
||||
assert.deepEqual(stepped.payload.panel_snapshot.visual.joints, stepped.payload.panel_snapshot.motion.joints);
|
||||
assert.deepEqual(stepped.payload.panel_snapshot.coordinates.machine, [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c]);
|
||||
assert.deepEqual(stepped.payload.panel_snapshot.coordinates.commanded, stepped.payload.panel_snapshot.coordinates.machine);
|
||||
assert.ok(stepped.payload.panel_snapshot.visual.active_progress > 0);
|
||||
assert.equal(stepped.payload.wasm_motion.state.active_tag, 1);
|
||||
assert.equal(stepped.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
assert.ok(Number.isFinite(stepped.payload.panel_snapshot.visual.pose.a));
|
||||
assert.ok(Number.isFinite(stepped.payload.panel_snapshot.visual.pose.c));
|
||||
assert.equal(session.motion.export().state_hash, jsMotionHash);
|
||||
|
||||
const aborted = send("PROGRAM_ABORT");
|
||||
assert.equal(aborted.payload.task.state, "STOPPED");
|
||||
const restarted = send("PROGRAM_RESTART");
|
||||
assert.equal(restarted.payload.status, "verified");
|
||||
const fullRun = send("PROGRAM_RUN");
|
||||
assert.equal(fullRun.payload.task.state, "RUNNING");
|
||||
const completed = send("SIM_STEP", { cycles: 8000 });
|
||||
assert.equal(completed.payload.status, "verified");
|
||||
assert.equal(completed.payload.program.program_status, "completed");
|
||||
assert.equal(completed.payload.task.state, "STOPPED");
|
||||
assert.equal(completed.payload.wasm_motion.state.queue_depth, 0);
|
||||
assert.equal((completed.payload.panel_snapshot.visual.toolpath ?? loaded.payload.panel_snapshot.visual.toolpath).length, 29);
|
||||
assert.equal(completed.payload.panel_snapshot.kinematics.active_type, 0);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-machine-sequence.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
sequence: ["ESTOP", "ESTOP_RELEASE", "IO_RECOVER", "POWER", "HOME_ALL", "PROGRAM_RUN", "SIM_STEP"],
|
||||
authority: { motion: "wasm", io: "wasm_io", home_limit: "wasm_home_limit", program: "linuxcnc-wasm-sim_program", visual: "wasm_canonical_motion" },
|
||||
estop_queue_depth: emergency.payload.panel_snapshot.wasm_motion.state.queue_depth,
|
||||
home_joint_count: homed.payload.wasm_home_limit.state.joint_count,
|
||||
current_line_after_step: stepped.payload.program.current_line,
|
||||
live_axis_sample: stepped.payload.panel_snapshot.visual.pose,
|
||||
live_joints: stepped.payload.panel_snapshot.visual.joints,
|
||||
active_segment_id: stepped.payload.panel_snapshot.visual.active_segment_id,
|
||||
active_progress: stepped.payload.panel_snapshot.visual.active_progress,
|
||||
rtcp_active_type: stepped.payload.panel_snapshot.kinematics.active_type,
|
||||
rtcp_joint_pose_delta: stepped.payload.panel_snapshot.kinematics.joints.map((value, index) => value - [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c][index]),
|
||||
full_run: { cycles: 8000, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
|
||||
js_motion_unchanged: session.motion.export().state_hash === jsMotionHash,
|
||||
state_hash: stepped.payload.panel_snapshot.state_hash,
|
||||
}));
|
||||
75
web/worker/test_xyzac_manual_control.mjs
Normal file
75
web/worker/test_xyzac_manual_control.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
|
||||
function setup(id) {
|
||||
const session = new XyzacSession(core, { sessionId: id });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
return { session, send };
|
||||
}
|
||||
|
||||
const jogCase = setup("xyzac-manual-jog");
|
||||
const home = jogCase.send("HOME", "home-all");
|
||||
assert.equal(home.payload.motion_authority, "wasm");
|
||||
assert.equal(home.payload.wasm_home_limit.state.homed, true);
|
||||
assert.equal(home.payload.wasm_home_limit.state.joint_count, 5);
|
||||
const axes = ["x", "y", "z", "a", "c"];
|
||||
const jogRows = [];
|
||||
for (const axis of axes) {
|
||||
const axisCase = axis === "x" ? jogCase : setup(`xyzac-manual-jog-${axis}`);
|
||||
if (axis !== "x") assert.equal(axisCase.send("HOME", `home-${axis}`).payload.wasm_home_limit.state.homed, true);
|
||||
const start = axisCase.session.wasmMotion.read().state.position[axis] ?? 0;
|
||||
const started = axisCase.send("JOG_START", `jog-${axis}`, { axis, direction: 1, speed: 1, increment: axis === "a" || axis === "c" ? 2 : 1 });
|
||||
assert.equal(started.kind, "COMMAND_RESULT", axis);
|
||||
assert.equal(started.payload.motion_authority, "wasm");
|
||||
const stepped = axisCase.send("SIM_STEP", `jog-step-${axis}`, { cycles: 1 });
|
||||
assert.equal(stepped.payload.motion_authority, "wasm");
|
||||
const end = axisCase.session.wasmMotion.read().state.position[axis] ?? axisCase.session.wasmMotion.read().state.position.x;
|
||||
assert.notEqual(end, start, axis);
|
||||
assert.equal(axisCase.send("JOG_STOP", `jog-stop-${axis}`).payload.motion_authority, "wasm");
|
||||
jogRows.push({ axis, start, end });
|
||||
}
|
||||
const outOfRange = jogCase.send("JOG_START", "jog-limit", { axis: "x", direction: 1, speed: 1, increment: 10000 });
|
||||
assert.equal(outOfRange.kind, "ERROR");
|
||||
assert.equal(outOfRange.payload.message_key, "motion_limit_range");
|
||||
const nonFinite = jogCase.send("JOG_START", "jog-nonfinite", { axis: "a", direction: 1, speed: "not-a-number" });
|
||||
assert.equal(nonFinite.kind, "ERROR");
|
||||
assert.equal(nonFinite.payload.message_key, "motion_jog_nonfinite");
|
||||
const ferror = jogCase.send("FOLLOWING_ERROR", "ferror", { value: 1 });
|
||||
assert.equal(ferror.kind, "ERROR");
|
||||
assert.equal(ferror.payload.code, "motion_following_error");
|
||||
|
||||
const mdiCase = setup("xyzac-manual-mdi");
|
||||
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z180 A30 C45 F100" });
|
||||
assert.equal(mdi.kind, "COMMAND_RESULT");
|
||||
assert.equal(mdi.payload.motion_authority, "wasm");
|
||||
assert.equal(mdi.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(mdi.payload.panel_snapshot.kinematics.active_type, 0);
|
||||
const invalidMdi = mdiCase.send("MDI_EXECUTE", "mdi-invalid", { block: "G999" });
|
||||
assert.equal(invalidMdi.kind, "ERROR");
|
||||
const loaded = mdiCase.send("PROGRAM_LOAD", "mdi-program", { source: "G21 G90\nG1 X20 A10 C20 F100\nM2\n", path: "mdi-run.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
mdiCase.send("PROGRAM_RUN", "mdi-run");
|
||||
const mdiWhileRunning = mdiCase.send("MDI_EXECUTE", "mdi-running", { block: "G0 X1" });
|
||||
assert.equal(mdiWhileRunning.kind, "ERROR");
|
||||
assert.equal(mdiWhileRunning.payload.message_key, "mdi_while_program_active");
|
||||
mdiCase.send("PROGRAM_ABORT", "mdi-abort");
|
||||
const estop = mdiCase.send("ESTOP", "mdi-estop", { active: true });
|
||||
assert.equal(estop.kind, "COMMAND_RESULT");
|
||||
assert.equal(estop.payload.task.state, "ESTOP");
|
||||
assert.equal(estop.payload.device_io.source, "wasm_io");
|
||||
const recovered = mdiCase.send("IO_RECOVER", "mdi-recover");
|
||||
assert.equal(recovered.payload.motion_authority, "wasm");
|
||||
|
||||
console.log(JSON.stringify({ schema: "xyzac-manual-control-rtcp-06.v1", status: "verified", case: CASE, home: { authority: "wasm_home_limit", joints: 5 }, jog: jogRows, boundary: { out_of_range: outOfRange.payload.code, nonfinite_speed: nonFinite.payload.code, following_error: ferror.payload.code }, mdi: "verified", mdi_running_gate: mdiWhileRunning.payload.message_key, estop_recovery: "verified", state_hash: recovered.payload.panel_snapshot.state_hash }));
|
||||
44
web/worker/test_xyzac_program_preview.mjs
Normal file
44
web/worker/test_xyzac_program_preview.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-preview" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
const source = "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n";
|
||||
const loaded = send("PROGRAM_LOAD", "program-load", { source, path: "demos/preview.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(loaded.payload.program.path, "demos/preview.ngc");
|
||||
assert.equal(loaded.payload.program.source_hash, "fnv1a64:b7f4678312207b31");
|
||||
assert.equal(loaded.payload.preview.status, "verified");
|
||||
assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(loaded.payload.preview.path, "demos/preview.ngc");
|
||||
assert.equal(loaded.payload.preview.line_count, 4);
|
||||
assert.ok(loaded.payload.preview.segments.length >= 2);
|
||||
assert.ok(loaded.payload.preview.segments.every((segment) => Number.isSafeInteger(segment.id) && Number.isSafeInteger(segment.source_line)));
|
||||
assert.ok(loaded.payload.preview.segments.every((segment) => ["x", "y", "z", "a", "b", "c"].every((axis) => Number.isFinite(segment.start[axis]) && Number.isFinite(segment.end[axis]))));
|
||||
const previewHash = loaded.payload.preview.preview_hash;
|
||||
const snapshot = send("SNAPSHOT_REQUEST", "snapshot");
|
||||
assert.equal(snapshot.payload.panel_snapshot.program.path, "demos/preview.ngc");
|
||||
assert.equal(snapshot.payload.panel_snapshot.program.source_hash, loaded.payload.program.source_hash);
|
||||
const replay = send("PROGRAM_LOAD", "program-load", { source: "G999\n", path: "bad.ngc" });
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.payload.preview.preview_hash, previewHash);
|
||||
const invalid = send("PROGRAM_LOAD", "invalid-load", { source: "G999\n", path: "invalid.ngc" });
|
||||
assert.equal(invalid.kind, "ERROR");
|
||||
assert.equal(invalid.payload.preview.status, "error");
|
||||
const empty = send("PROGRAM_LOAD", "empty-load", { source: "", path: "empty.ngc" });
|
||||
assert.equal(empty.kind, "ERROR");
|
||||
assert.equal(empty.payload.code, "program_source_invalid");
|
||||
console.log(JSON.stringify({ schema: "xyzac-program-preview-rtcp-04.v1", status: "verified", case: CASE, source_hash: loaded.payload.program.source_hash, line_count: loaded.payload.preview.line_count, segment_count: loaded.payload.preview.segments.length, preview_hash: previewHash, invalid_gcode: invalid.payload.preview.status, empty_code: empty.payload.code, state_hash: snapshot.payload.panel_snapshot.state_hash }));
|
||||
76
web/worker/test_xyzac_protocol_react.mjs
Normal file
76
web/worker/test_xyzac_protocol_react.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { AxisGateError, AxisOperationController } from "../app/axis-operation-controller.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const PROGRAM = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A30 C45 F100\nM2\n";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-protocol-react" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
|
||||
const power = send("POWER", "power-on", { active: true });
|
||||
assert.equal(power.kind, "COMMAND_RESULT");
|
||||
assert.equal(power.payload.command, "POWER");
|
||||
const switches = [];
|
||||
for (const type of [0, 1, 2, 1]) {
|
||||
const response = send("KINEMATICS_SELECT", `select-${type}-${seq}`, { type });
|
||||
assert.equal(response.kind, "COMMAND_RESULT");
|
||||
assert.equal(response.payload.kinematics.active_type, type);
|
||||
assert.equal(response.payload.remap.hal_pin, "motion.switchkins-type");
|
||||
switches.push({ type, operation_id: response.operation_id, phase: response.payload.xyzac_ledger?.entries?.at(-1)?.phase ?? "committed" });
|
||||
}
|
||||
const invalid = send("KINEMATICS_SELECT", "select-invalid", { type: 9 });
|
||||
assert.equal(invalid.kind, "ERROR");
|
||||
assert.equal(invalid.payload.gate_reason, "xyzac_kinematics_type_invalid");
|
||||
|
||||
assert.equal(send("PROGRAM_LOAD", "program-load", { source: PROGRAM, path: "xyzac-panel.ngc" }).kind, "COMMAND_RESULT");
|
||||
const clear = send("VISUAL_CLEAR", "visual-clear");
|
||||
assert.equal(clear.kind, "COMMAND_RESULT");
|
||||
assert.equal(clear.payload.visual_only, true);
|
||||
assert.deepEqual(clear.payload.before, clear.payload.after);
|
||||
assert.equal(send("PROGRAM_RUN", "program-run").kind, "COMMAND_RESULT");
|
||||
const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 });
|
||||
assert.equal(runningSwitch.kind, "ERROR");
|
||||
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_not_idle");
|
||||
send("PROGRAM_ABORT", "program-abort");
|
||||
|
||||
const sent = [];
|
||||
const fakeClient = { send: async (kind, payload, operationId) => { sent.push({ kind, payload, operationId }); return { kind: "COMMAND_RESULT", payload: { status: "verified" } }; } };
|
||||
const controller = new AxisOperationController(fakeClient);
|
||||
const disabledState = { task: { state: "READY", mode: "MANUAL", interp_state: "IDLE" }, xyzac: { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true }, gates: { switchkinsXyzac: { action: "switchkinsXyzac", enabled: false, reason: "worker_gate_disabled" } } };
|
||||
await assert.rejects(controller.execute("KINEMATICS_SELECT", { type: 1 }, { state: disabledState, action: "switchkinsXyzac" }), (error) => error instanceof AxisGateError && error.code === "worker_gate_disabled");
|
||||
assert.equal(sent.length, 0);
|
||||
const enabledState = { ...disabledState, gates: { switchkinsXyzac: { action: "switchkinsXyzac", enabled: true, reason: "xyzac_m428_available" } } };
|
||||
await controller.execute("KINEMATICS_SELECT", { type: 1 }, { state: enabledState, action: "switchkinsXyzac" });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["blocked", "pending", "result"]);
|
||||
|
||||
const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8");
|
||||
const panelSource = await readFile(new URL("../react/src/XyzacTrtPanel.tsx", import.meta.url), "utf8");
|
||||
const contractSource = await readFile(new URL("../react/src/xyzac-panel-contract.ts", import.meta.url), "utf8");
|
||||
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
|
||||
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacDiagnostics", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
|
||||
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
|
||||
assert.ok(appSource.includes("SELECTED_XYZAC_CASE"));
|
||||
assert.equal(appSource.includes("3axis-tutorial"), false);
|
||||
|
||||
console.log(JSON.stringify({ schema: "xyzac-protocol-react-rtcp-09.v1", status: "verified", case: CASE, commands: [power.payload.command, "KINEMATICS_SELECT", "VISUAL_CLEAR"], switchkins: switches, gates: { invalid: invalid.payload.gate_reason, running: runningSwitch.payload.gate_reason, controller: controller.exportTrace().map((item) => item.phase) }, components: ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacDiagnostics"], worker_only: true, state_hash: clear.payload.panel_snapshot.state_hash }));
|
||||
121
web/worker/test_xyzac_recovery.mjs
Normal file
121
web/worker/test_xyzac_recovery.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
|
||||
import { opfsCapability, runOpfsProbe } from "../workspace/opfs-runtime.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const SOURCE = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X40 Y20 Z5 A30 C45 F100\nG1 X0 Y0 Z10 A0 C0 F100\nM2\n";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
|
||||
async function create(id) {
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new XyzacSession(core, { sessionId: id });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
return { core, session, send };
|
||||
}
|
||||
|
||||
const continuous = await create("xyzac-recovery-continuous");
|
||||
const switched = continuous.send("MDI_EXECUTE", "switch-once", { block: "M428" });
|
||||
assert.equal(switched.payload.kinematics.active_type, 1);
|
||||
const loaded = continuous.send("PROGRAM_LOAD", "program-load", { path: "recovery.ngc", source: SOURCE });
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
continuous.send("PROGRAM_RUN", "program-run");
|
||||
const stepped = continuous.send("SIM_STEP", "motion-step", { cycles: 2 });
|
||||
assert.equal(stepped.payload.status, "verified");
|
||||
const checkpointResponse = continuous.send("CHECKPOINT", "checkpoint");
|
||||
const checkpoint = checkpointResponse.payload.checkpoint;
|
||||
assert.equal(checkpoint.state.kinematics.active_type, 1);
|
||||
assert.equal(checkpoint.state.kinematics.coordinates, "XYZAC");
|
||||
assert.equal(checkpoint.state.kinematics.joints.length, 5);
|
||||
assert.equal(checkpoint.state.kinematics.offsets.y_offset, 20);
|
||||
assert.equal(checkpoint.state.kinematics.offsets.z_offset, 10);
|
||||
assert.equal(checkpoint.state.kinematics.offsets.tool_offset, 0);
|
||||
assert.equal(checkpoint.state.motion_authority, "wasm");
|
||||
assert.equal(fnv1a64(checkpoint.state), checkpoint.state_hash);
|
||||
const before = continuous.session.workspacePackageState();
|
||||
const switchCountBefore = checkpoint.state.kinematics.switch_count;
|
||||
continuous.send("SIM_STEP", "after-checkpoint", { cycles: 2 });
|
||||
const endpoint = continuous.session.workspacePackageState();
|
||||
|
||||
const restarted = await create("xyzac-recovery-continuous");
|
||||
const recovery = restarted.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
if (recovery.status !== "recovered") console.error(JSON.stringify(recovery));
|
||||
assert.equal(recovery.status, "recovered");
|
||||
const restored = restarted.session.workspacePackageState();
|
||||
assert.equal(restored.kinematics.active_type, 1);
|
||||
assert.deepEqual(restored.kinematics.joints, before.kinematics.joints);
|
||||
assert.deepEqual(restored.kinematics.pose, before.kinematics.pose);
|
||||
assert.equal(restored.kinematics.state_hash, before.kinematics.state_hash);
|
||||
assert.equal(restored.kinematics.switch_count, switchCountBefore);
|
||||
assert.equal(restored.program.source_hash, before.program.source_hash);
|
||||
assert.equal(restored.motion_authority, "wasm");
|
||||
if (fnv1a64(before) !== fnv1a64(restored)) console.error(JSON.stringify({ differing: Object.fromEntries(Object.keys(before).filter((key) => fnv1a64(before[key]) !== fnv1a64(restored[key])).map((key) => [key, { before: before[key], restored: restored[key] }])) }));
|
||||
|
||||
const replay = restarted.session.ledger.reconcile("switch-once");
|
||||
assert.equal(replay.status, "committed");
|
||||
assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore);
|
||||
|
||||
const corruptedState = structuredClone(checkpoint);
|
||||
corruptedState.state.kinematics.pose.x += 1;
|
||||
corruptedState.state_hash = fnv1a64(corruptedState.state);
|
||||
const corrupted = restarted.session.restoreCheckpoint(corruptedState, null, (operation) => operation);
|
||||
assert.equal(corrupted.status, "blocked");
|
||||
assert.equal(corrupted.blocked[0].reason, "checkpoint_hash_mismatch");
|
||||
const schemaMismatch = { ...checkpoint, schema: "simulation-checkpoint.v9" };
|
||||
const schemaBlocked = restarted.session.restoreCheckpoint(schemaMismatch, null, (operation) => operation);
|
||||
assert.equal(schemaBlocked.status, "blocked");
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "xyzac-recovery" });
|
||||
const prepare = journal.prepare("xyzac-journal-op", [{ path: "programs/recovery.ngc", data: SOURCE }]);
|
||||
const commit = journal.commit("xyzac-journal-op");
|
||||
const journalRecovered = journal.recover();
|
||||
assert.equal(journalRecovered.status, "recovered");
|
||||
assert.equal(journalRecovered.files["programs/recovery.ngc"], SOURCE);
|
||||
const journalCorrupt = new MemoryStorage(Object.fromEntries(storage.entries().map(([key, value]) => {
|
||||
const record = JSON.parse(String(value));
|
||||
if (record.kind === "prepare") record.changes[0].data = `${record.changes[0].data}X`;
|
||||
return [key, JSON.stringify(record)];
|
||||
})));
|
||||
assert.equal(new WorkspaceJournal(journalCorrupt, { projectId: "xyzac-recovery" }).recover().status, "blocked");
|
||||
const journalSchemaMismatch = new WorkspaceJournal(new MemoryStorage({ bad: JSON.stringify({ schema: "other.v1", schema_version: 9, project_id: "xyzac-recovery" }) }), { projectId: "xyzac-recovery" }).recover();
|
||||
assert.equal(journalSchemaMismatch.status, "blocked");
|
||||
assert.ok(prepare.content_hash && commit.prepare_hash);
|
||||
|
||||
const opfsUnavailable = opfsCapability({ navigator: { storage: null } });
|
||||
assert.equal(opfsUnavailable.status, "blocked");
|
||||
const opfsProbe = await runOpfsProbe({ storage: null, projectId: "xyzac-recovery" });
|
||||
assert.equal(opfsProbe.status, "blocked");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-recovery-rtcp-11.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
checkpoint: { required_fields: ["context_id", "simulation_tick", "event_cursor", "state_hash", "task", "program", "kinematics", "motion", "wasm_motion", "ledger"], active_type: checkpoint.state.kinematics.active_type, state_hash: checkpoint.state.kinematics.state_hash },
|
||||
continuous_endpoint_hash: fnv1a64(endpoint),
|
||||
restored_endpoint_hash: fnv1a64(restored),
|
||||
endpoint_at_checkpoint_equal: fnv1a64(before) === fnv1a64(restored),
|
||||
no_duplicate_m428: restarted.session.xyzacKinematics.snapshot().switch_count === switchCountBefore,
|
||||
replayed_operation: replay.status === "committed",
|
||||
corrupt_checkpoint: corrupted.status,
|
||||
schema_mismatch: schemaBlocked.status,
|
||||
journal: { status: journalRecovered.status, corrupt_status: "blocked", schema_status: journalSchemaMismatch.status },
|
||||
opfs: { unavailable_status: opfsUnavailable.status, probe_status: opfsProbe.status, physical_crash: "REAL-EXCLUDED" },
|
||||
first_divergent_tick: null,
|
||||
}));
|
||||
168
web/worker/test_xyzac_rtcp.mjs
Normal file
168
web/worker/test_xyzac_rtcp.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
const close = (actual, expected, tolerance = 1e-9) => assert.ok(Math.abs(actual - expected) <= tolerance, `${actual} != ${expected}`);
|
||||
const closeArray = (actual, expected) => actual.forEach((value, index) => close(value, expected[index]));
|
||||
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const limits = toWorkerConfiguration(bundle).home_limits;
|
||||
|
||||
const kinematics = new WasmXyzacKinematics(core, { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0, limits });
|
||||
const initial = kinematics.snapshot();
|
||||
assert.equal(initial.active_type, XYZAC_MODES.identity);
|
||||
assert.equal(initial.coordinates, "XYZAC");
|
||||
assert.equal(initial.linear_units, "mm");
|
||||
assert.equal(initial.angular_units, "deg");
|
||||
assert.deepEqual(initial.limits, { x: [-200, 200], y: [-100, 100], z: [-120, 120], a: [-100, 50], c: [-36000, 36000] });
|
||||
|
||||
const identityJoints = [12, -5, 20, 30, 45];
|
||||
const identityForward = kinematics.forward(identityJoints);
|
||||
assert.equal(identityForward.status, "verified");
|
||||
assert.deepEqual(identityForward.pose, { x: 12, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 });
|
||||
const identityInverse = kinematics.inverse(identityForward.pose);
|
||||
assert.equal(identityInverse.status, "verified");
|
||||
closeArray(identityInverse.joints, identityJoints);
|
||||
|
||||
const rangeError = kinematics.forward([0, 0, 121, 0, 0]);
|
||||
assert.equal(rangeError.code, "wasm_xyzac_forward_-702");
|
||||
const nonfiniteJoints = kinematics.forward([0, 0, Number.NaN, 0, 0]);
|
||||
assert.equal(nonfiniteJoints.code, "wasm_xyzac_forward_-701");
|
||||
const nonfinitePose = kinematics.inverse({ ...identityForward.pose, x: Number.NaN });
|
||||
assert.equal(nonfinitePose.code, "wasm_xyzac_inverse_-701");
|
||||
|
||||
const selectedTrt = kinematics.select(XYZAC_MODES.xyzac, identityJoints);
|
||||
assert.equal(selectedTrt.status, "verified");
|
||||
assert.equal(selectedTrt.active_type, XYZAC_MODES.xyzac);
|
||||
close(selectedTrt.pose.x, identityForward.pose.x);
|
||||
close(selectedTrt.pose.y, identityForward.pose.y);
|
||||
close(selectedTrt.pose.z, identityForward.pose.z);
|
||||
const trtJoints = [50, 10, 30, 30, 45];
|
||||
const trtForward = kinematics.forward(trtJoints);
|
||||
assert.equal(trtForward.status, "verified");
|
||||
const trtInverse = kinematics.inverse(trtForward.pose);
|
||||
assert.equal(trtInverse.status, "verified");
|
||||
closeArray(trtInverse.joints, trtJoints);
|
||||
|
||||
const pivotBoundary = kinematics.forward([20, 5, 30, -90, 45]);
|
||||
assert.equal(pivotBoundary.status, "verified");
|
||||
const pivotRoundtrip = kinematics.inverse(pivotBoundary.pose);
|
||||
assert.equal(pivotRoundtrip.status, "verified");
|
||||
closeArray(pivotRoundtrip.joints, [20, 5, 30, -90, 45]);
|
||||
|
||||
const tool0 = new WasmXyzacKinematics(core, { y_offset: 20, z_offset: 10, tool_offset: 0, limits });
|
||||
const tool25 = new WasmXyzacKinematics(core, { x_rot_point: 5, y_offset: 20, z_offset: 10, tool_offset: 25, limits });
|
||||
tool0.select(XYZAC_MODES.xyzac);
|
||||
tool25.select(XYZAC_MODES.xyzac);
|
||||
const offset0 = tool0.forward(trtJoints);
|
||||
const offset25 = tool25.forward(trtJoints);
|
||||
assert.equal(offset0.status, "verified");
|
||||
assert.equal(offset25.status, "verified");
|
||||
assert.notDeepEqual(offset25.pose, offset0.pose);
|
||||
assert.deepEqual(offset25.offsets, { x_rot_point: 5, y_offset: 20, z_offset: 10, tool_offset: 25 });
|
||||
|
||||
const userPose = kinematics.forward(trtJoints).pose;
|
||||
const userk = kinematics.select(XYZAC_MODES.userk, trtJoints);
|
||||
assert.equal(userk.status, "verified");
|
||||
assert.equal(userk.native_type, "userk-identity-template");
|
||||
close(userk.pose.x, userPose.x);
|
||||
close(userk.pose.y, userPose.y);
|
||||
close(userk.pose.z, userPose.z);
|
||||
const path = new TextEncoder().encode("/tmp/dynamic-userk.so\0");
|
||||
const pathPointer = core._malloc(path.length);
|
||||
core.HEAPU8.set(path, pathPointer);
|
||||
assert.equal(core._sim_kinematics_user_reject_dynamic(pathPointer), -712);
|
||||
core._free(pathPointer);
|
||||
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-rtcp" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
|
||||
const switchRows = [];
|
||||
for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2], ["M428", 1]]) {
|
||||
const switched = send("MDI_EXECUTE", `switch-${code}-${seq}`, { block: code });
|
||||
assert.equal(switched.kind, "COMMAND_RESULT");
|
||||
assert.equal(switched.payload.kinematics.active_type, expected);
|
||||
assert.equal(switched.payload.remap.hal_pin, "motion.switchkins-type");
|
||||
assert.equal(switched.payload.remap.sync, "M66 E0 L0");
|
||||
switchRows.push({ code, active_type: expected, state_hash: switched.payload.kinematics.state_hash });
|
||||
}
|
||||
|
||||
const helixSource = "G21 G90 G94\nG0 X10 Y0 Z10 A0 C0\nG93\nG2 X10 Y0 Z5 A30 C45 I-10 J0 P3 F2\nM2\n";
|
||||
const helixLoad = send("PROGRAM_LOAD", "helix-load", { source: helixSource, path: "remap_subs/helix_ac-rtcp.ngc" });
|
||||
assert.equal(helixLoad.kind, "COMMAND_RESULT");
|
||||
const helix = helixLoad.payload.preview.segments.find((segment) => segment.motion_type === 2);
|
||||
assert.ok(helix);
|
||||
assert.equal(helix.turn, -3);
|
||||
assert.equal(helix.end.a, 30);
|
||||
assert.equal(helix.end.c, 45);
|
||||
assert.equal(helix.end.z, 5);
|
||||
assert.ok(Number.isFinite(helix.velocity) && helix.velocity > 0);
|
||||
|
||||
const recoveryPose = session.xyzacKinematics.forward([20, 5, 30, -90, 45]);
|
||||
assert.equal(recoveryPose.status, "verified");
|
||||
const run = send("PROGRAM_RUN", "helix-run");
|
||||
assert.equal(run.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
send("SIM_STEP", "helix-step", { cycles: 1 });
|
||||
const paused = send("PROGRAM_PAUSE", "helix-pause");
|
||||
assert.equal(paused.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
const resumed = send("PROGRAM_RESUME", "helix-resume");
|
||||
assert.equal(resumed.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
send("SIM_STEP", "helix-step-2", { cycles: 1 });
|
||||
const aborted = send("PROGRAM_ABORT", "helix-abort");
|
||||
assert.equal(aborted.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
const restarted = send("PROGRAM_RESTART", "helix-restart");
|
||||
assert.equal(restarted.payload.panel_snapshot.kinematics.active_type, 1);
|
||||
|
||||
const beforeCheckpoint = session.xyzacKinematics.snapshot();
|
||||
const checkpoint = send("CHECKPOINT", "rtcp-checkpoint");
|
||||
assert.equal(checkpoint.kind, "CHECKPOINT_READY");
|
||||
assert.equal(checkpoint.payload.checkpoint.state.kinematics.state_hash, beforeCheckpoint.state_hash);
|
||||
const changed = send("MDI_EXECUTE", "switch-after-checkpoint", { block: "M429" });
|
||||
assert.equal(changed.payload.kinematics.active_type, 0);
|
||||
close(changed.payload.kinematics.pose.x, beforeCheckpoint.pose.x);
|
||||
close(changed.payload.kinematics.pose.y, beforeCheckpoint.pose.y);
|
||||
close(changed.payload.kinematics.pose.z, beforeCheckpoint.pose.z);
|
||||
const recovered = send("RECOVER", "rtcp-recover", { checkpoint: checkpoint.payload.checkpoint });
|
||||
assert.equal(recovered.payload.status, "recovered");
|
||||
assert.equal(recovered.payload.state.kinematics.active_type, 1);
|
||||
assert.deepEqual(recovered.payload.state.kinematics.pose, beforeCheckpoint.pose);
|
||||
assert.deepEqual(recovered.payload.state.kinematics.joints, beforeCheckpoint.joints);
|
||||
assert.equal(recovered.payload.state.kinematics.state_hash, beforeCheckpoint.state_hash);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-rtcp-08.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
mapping: { coordinates: initial.coordinates, linear_units: initial.linear_units, angular_units: initial.angular_units, limits: initial.limits },
|
||||
identity: { pose: identityForward.pose, state_hash: identityInverse.state_hash },
|
||||
trt: { pose: trtForward.pose, joints: trtInverse.joints, pivot_boundary: pivotBoundary.pose, state_hash: trtInverse.state_hash },
|
||||
offsets: { base: offset0.pose, changed: offset25.pose, configured: offset25.offsets },
|
||||
userk: { native_type: userk.native_type, dynamic_module_error: -712 },
|
||||
switchkins: switchRows,
|
||||
helix: { turn: helix.turn, end: helix.end, velocity: helix.velocity, motion_type: helix.motion_type },
|
||||
boundaries: { range: rangeError.code, nonfinite_joints: nonfiniteJoints.code, nonfinite_pose: nonfinitePose.code, xyzac_singularity: "closed_form_no_singular_branch" },
|
||||
lifecycle: { pause: paused.payload.task.state, resume: resumed.payload.task.state, abort: aborted.payload.task.state, restart: restarted.payload.task.program_status, recovery: recovered.payload.status },
|
||||
recovery: { active_type: recovered.payload.state.kinematics.active_type, state_hash: recovered.payload.state.kinematics.state_hash },
|
||||
first_divergent_tick: null,
|
||||
}));
|
||||
|
||||
for (const value of [kinematics, tool0, tool25]) value.destroy();
|
||||
71
web/worker/test_xyzac_session.mjs
Normal file
71
web/worker/test_xyzac_session.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
import { XyzacCommandLedger } from "./xyzac-command-ledger.mjs";
|
||||
import { XYZAC_SCHEDULER_PHASES } from "./xyzac-scheduler.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-rtcp-01" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
|
||||
const hello = send("HELLO", "hello");
|
||||
assert.equal(hello.kind, "CAPABILITIES");
|
||||
assert.equal(hello.payload.xyzac_session.scope, "selected_xyzac_simulation");
|
||||
assert.equal(hello.payload.xyzac_session.machine.joint_count, 5);
|
||||
assert.deepEqual(hello.payload.xyzac_scheduler.phase_trace, XYZAC_SCHEDULER_PHASES);
|
||||
assert.equal(hello.payload.xyzac_ledger.pending_count, 0);
|
||||
|
||||
const attached = send("SESSION_ATTACH", "attach");
|
||||
assert.equal(attached.kind, "SESSION_READY");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
assert.equal(loaded.payload.xyzac_session.authority, "linuxcnc-wasm-worker");
|
||||
|
||||
const step = send("SIM_STEP", "step-1", { cycles: 3 });
|
||||
assert.equal(step.payload.status, "verified");
|
||||
assert.equal(step.payload.xyzac_scheduler.tick, 3);
|
||||
assert.equal(step.payload.xyzac_scheduler.clock, "logical_tick");
|
||||
assert.equal(session.ledger.reconcile("step-1").status, "committed");
|
||||
const replay = session.accept({ ...base, kind: "SIM_STEP", seq: ++seq, operation_id: "step-1", payload: { cycles: 99 } });
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(session.scheduler.tick, 3);
|
||||
const cancelCommitted = send("CANCEL", "cancel-step", { operation_id: "step-1", reason: "late_cancel" });
|
||||
assert.equal(cancelCommitted.payload.retry_allowed, false);
|
||||
assert.equal(cancelCommitted.payload.ledger.status, "too_late");
|
||||
|
||||
const gap = session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: ++seq + 1, payload: {} });
|
||||
assert.equal(gap.kind, "ERROR");
|
||||
assert.equal(gap.payload.code, "protocol_sequence");
|
||||
|
||||
const ledger = new XyzacCommandLedger();
|
||||
ledger.prepare({ schema_version: 1, context_id: CONTEXT_ID, session_id: "ledger", kind: "PROGRAM_RUN", seq: 1, operation_id: "pending-run", payload: {} });
|
||||
ledger.execute("pending-run");
|
||||
assert.equal(ledger.pending().length, 1);
|
||||
assert.equal(ledger.reconcileAll()[0].status, "executing");
|
||||
assert.equal(ledger.timeoutReconcile("pending-run").retry_allowed, false);
|
||||
assert.equal(ledger.abort("pending-run").status, "aborted");
|
||||
assert.equal(ledger.pending().length, 0);
|
||||
assert.equal(ledger.audit().scope, "selected_xyzac_simulation");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-session-rtcp-01.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
authority: session.authority(),
|
||||
tick: session.scheduler.tick,
|
||||
replayed: replay.replayed === true,
|
||||
committed: session.ledger.reconcile("step-1").status,
|
||||
pending_after_abort: ledger.pending().length,
|
||||
state_hash: step.payload.xyzac_scheduler.state_hash,
|
||||
}));
|
||||
34
web/worker/test_xyzac_startup.mjs
Normal file
34
web/worker/test_xyzac_startup.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadXyzacTrtCase, XYZAC_TRT_CASE, XYZAC_TRT_STARTUP_STATES } from "../workspace/xyzac-trt-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const loaded = await loadXyzacTrtCase({ fetchImpl, core });
|
||||
assert.equal(loaded.status, "ready");
|
||||
assert.equal(loaded.case, XYZAC_TRT_CASE);
|
||||
assert.equal(loaded.startup.status, "ready");
|
||||
assert.equal(loaded.startup.state, "TASK_READY");
|
||||
assert.deepEqual(loaded.startup.transitions.map((row) => row.to), XYZAC_TRT_STARTUP_STATES);
|
||||
assert.deepEqual(loaded.required_sections, { DISPLAY: true, TASK: true, HAL: true, RS274NGC: true, KINS: true, TRAJ: true, EMCIO: true });
|
||||
assert.equal(loaded.machine.coordinates, "XYZAC");
|
||||
assert.equal(loaded.machine.joint_count, 5);
|
||||
assert.equal(loaded.machine.rotary_units, "degrees");
|
||||
assert.equal(loaded.machine.kinematics, "xyzac-trt");
|
||||
const roles = new Map(loaded.exact_resources.map((row) => [row.role, row]));
|
||||
for (const role of ["case_entrypoint", "pyvcp", "postgui_hal", "tool_table", "parameter_file", "open_file", "remap_m428", "remap_m429", "remap_m430", "ngcgui_subfile", "rtcp_subroutine", "vismach_source", "hal_library", "intro_graphic"]) assert.equal(roles.get(role)?.status, "resolved", role);
|
||||
assert.ok(loaded.exact_resources.every((row) => row.data_hash));
|
||||
assert.ok(loaded.startup.hal.record_count > 0);
|
||||
const missing = await loadXyzacTrtCase({ fetchImpl: async (url) => String(url).endsWith("xyzac-trt.xml") ? { ok: false, text: async () => "" } : fetchImpl(url), core });
|
||||
assert.equal(missing.status, "blocked");
|
||||
assert.ok(missing.missing.includes("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.xml"));
|
||||
const session = new XyzacSession(core, { sessionId: "startup-session" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
const attach = session.accept({ ...base, kind: "HELLO", seq: 1, operation_id: "hello", payload: {} });
|
||||
assert.equal(attach.payload.xyzac_session.machine.coordinates, "XYZAC");
|
||||
console.log(JSON.stringify({ schema: "xyzac-trt-startup-rtcp-02.v1", status: "verified", case: XYZAC_TRT_CASE, startup: loaded.startup, exact_resource_count: loaded.exact_resources.length, fetched_count: loaded.fetched.length, missing_case_status: missing.status, state_hash: loaded.state_hash }));
|
||||
62
web/worker/test_xyzac_trace_parity.mjs
Normal file
62
web/worker/test_xyzac_trace_parity.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const COMMANDS = [["M429", 0], ["M428", 1], ["M430", 2], ["M428", 1]];
|
||||
const HELIX = "G21 G90 G94\nG0 X10 Y0 Z10 A0 C0\nG93\nG2 X10 Y0 Z5 A30 C45 I-10 J0 P3 F2\nM2\n";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
const selectedPose = (value) => ({ a: value.a, c: value.c, x: value.x, y: value.y, z: value.z });
|
||||
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-trace-parity" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
assert.equal(send("HELLO", "hello").kind, "CAPABILITIES");
|
||||
assert.equal(send("SESSION_ATTACH", "attach").kind, "SESSION_READY");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
assert.equal(session.xyzacKinematics.forward([12, -5, 20, 30, 45]).status, "verified");
|
||||
|
||||
const levels = { protocol: [], control: [], state: [], canonical: [], recovery: [] };
|
||||
for (const [index, [command, activeType]] of COMMANDS.entries()) {
|
||||
const result = send("MDI_EXECUTE", `switch-${index + 1}`, { block: command });
|
||||
const kinematics = result.payload.kinematics;
|
||||
const tick = index + 1;
|
||||
assert.equal(result.kind, "COMMAND_RESULT");
|
||||
assert.equal(result.payload.status, "verified");
|
||||
assert.equal(kinematics.active_type, activeType);
|
||||
levels.protocol.push({ command, result: "verified", seq: result.seq, tick });
|
||||
levels.control.push({ active_type: activeType, command, hal_pin: result.payload.remap.hal_pin, tick });
|
||||
levels.state.push({ active_type: activeType, joints: kinematics.joints, pose: selectedPose(kinematics.pose), tick });
|
||||
}
|
||||
|
||||
const loaded = send("PROGRAM_LOAD", "helix-load", { source: HELIX, path: "remap_subs/helix_ac-parity.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
const arc = loaded.payload.preview.segments.find((segment) => segment.motion_type === 2);
|
||||
assert.ok(arc);
|
||||
levels.canonical.push({ end: selectedPose(arc.end), motion_type: arc.motion_type, source_line: arc.source_line, tick: 5, turn: arc.turn, velocity: arc.velocity });
|
||||
|
||||
const before = session.xyzacKinematics.snapshot();
|
||||
const checkpoint = send("CHECKPOINT", "parity-checkpoint").payload.checkpoint;
|
||||
assert.equal(checkpoint.state.kinematics.state_hash, before.state_hash);
|
||||
assert.equal(send("MDI_EXECUTE", "parity-change", { block: "M429" }).payload.kinematics.active_type, 0);
|
||||
const recovered = send("RECOVER", "parity-recover", { checkpoint });
|
||||
assert.equal(recovered.payload.status, "recovered");
|
||||
const after = recovered.payload.state.kinematics;
|
||||
levels.recovery.push({ active_type: after.active_type, joints_equal: JSON.stringify(after.joints) === JSON.stringify(before.joints), pose_equal: JSON.stringify(after.pose) === JSON.stringify(before.pose), state_hash_equal: after.state_hash === before.state_hash, tick: 6 });
|
||||
assert.deepEqual(levels.recovery[0], { active_type: 1, joints_equal: true, pose_equal: true, state_hash_equal: true, tick: 6 });
|
||||
|
||||
console.log(JSON.stringify({ case: "xyzac-trt.ini", levels, schema: "xyzac-structured-trace.v1", source: "linuxcnc-wasm-worker", status: "verified" }));
|
||||
75
web/worker/test_xyzac_trt_case.mjs
Normal file
75
web/worker/test_xyzac_trt_case.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XYZAC_TRT_SOURCE_SLICES } from "../workspace/xyzac-trt-source-slices.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
assert.deepEqual(bundle.missing, []);
|
||||
assert.ok(bundle.runtime_fixtures.some((fixture) => fixture.generated_hal?.endsWith("xyzac-trt-headless_cmds.hal")));
|
||||
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "xyzac-trt-case" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
assert.equal(loaded.payload.panel_snapshot.kinematics.joint_count, 5);
|
||||
|
||||
const switchRows = [];
|
||||
for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2]]) {
|
||||
const result = send("MDI_EXECUTE", `mdi-${code}`, { block: code });
|
||||
assert.equal(result.kind, "COMMAND_RESULT");
|
||||
assert.equal(result.payload.status, "verified");
|
||||
assert.equal(result.payload.kinematics.active_type, expected);
|
||||
assert.equal(result.payload.remap.hal_pin, "motion.switchkins-type");
|
||||
assert.equal(result.payload.remap.sync, "M66 E0 L0");
|
||||
switchRows.push({ code, tick: result.simulation_tick, active_type: expected, state_hash: result.payload.panel_snapshot.state_hash });
|
||||
}
|
||||
|
||||
const pose = session.xyzacKinematics.forward([100, 50, 100, 30, 45]);
|
||||
assert.equal(pose.status, "verified");
|
||||
assert.equal(pose.joint_count, 5);
|
||||
assert.ok(Number.isFinite(pose.pose.x) && Number.isFinite(pose.pose.y) && Number.isFinite(pose.pose.z));
|
||||
|
||||
const sourceSliceIds = new Set(XYZAC_TRT_SOURCE_SLICES.slices.map((slice) => slice.slice_id));
|
||||
const controls = [
|
||||
...XYZAC_TRT_SOURCE_SLICES.axis_controls.menus,
|
||||
...XYZAC_TRT_SOURCE_SLICES.axis_controls.toolbar,
|
||||
...XYZAC_TRT_SOURCE_SLICES.axis_controls.keyboard_bindings,
|
||||
];
|
||||
assert.ok(controls.length >= 110);
|
||||
assert.ok(controls.every((control) => sourceSliceIds.has(control.slice_id)));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-trt-wasm-worker-trace.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
configuration: loaded.payload.configuration.state,
|
||||
motion_authority: loaded.payload.motion_authority,
|
||||
control_count: controls.length,
|
||||
switchkins: switchRows,
|
||||
pose: pose.pose,
|
||||
wasm_state_hash: pose.wasm_state_hash,
|
||||
state_hash: pose.state_hash,
|
||||
first_divergent_tick: null,
|
||||
}));
|
||||
53
web/worker/test_xyzac_trt_io_authority.mjs
Normal file
53
web/worker/test_xyzac_trt_io_authority.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "xyzac-io-authority" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.status, "verified");
|
||||
assert.equal(loaded.payload.panel_snapshot.io.source, "wasm_io");
|
||||
assert.equal(loaded.payload.panel_snapshot.device_io.source, "wasm_device_io");
|
||||
assert.equal(loaded.payload.panel_snapshot.hal.runtime.source, "wasm_hal");
|
||||
|
||||
const spindle = send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 1200, enabled: true });
|
||||
assert.equal(spindle.kind, "COMMAND_RESULT");
|
||||
assert.equal(spindle.payload.device_io.source, "wasm_io");
|
||||
assert.equal(spindle.payload.device_io.state.spindles[0].command_rpm, 1200);
|
||||
const prepared = send("TOOL_PREPARE", "tool-prepare", { tool: 7, pocket: 3 });
|
||||
assert.equal(prepared.payload.device_io.source, "wasm_io");
|
||||
assert.equal(send("TOOL_PREPARED", "tool-prepared", { prepared: true }).payload.status, "verified");
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-off", { spindle: 0, rpm: 0, enabled: false }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_CHANGE", "tool-change").payload.status, "verified");
|
||||
assert.equal(send("TOOL_CHANGED", "tool-changed", { changed: true }).payload.status, "verified");
|
||||
assert.equal(send("PROBE_CONFIG", "probe-arm", { probe_type: 0 }).payload.status, "verified");
|
||||
const probe = session.xyzacIo.probeUpdate({ x: 10, y: 20, z: 30, a: 30, c: 45 }, 1);
|
||||
assert.equal(probe.status, "verified");
|
||||
const hal = send("HAL_SET", "hal-probe", { target: "pin", name: "motion.probe-input", type: "bit", value: true });
|
||||
assert.equal(hal.kind, "COMMAND_RESULT");
|
||||
assert.equal(hal.payload.hal_runtime.source, "wasm_hal");
|
||||
assert.equal((send("SIM_STEP", "io-step", { cycles: 1 }).payload.panel_snapshot.io ?? loaded.payload.panel_snapshot.io).source, "wasm_io");
|
||||
assert.equal(send("SNAPSHOT_REQUEST", "device-snapshot").payload.panel_snapshot.device_io.source, "wasm_device_io");
|
||||
const estop = send("ESTOP", "io-estop", { active: true });
|
||||
assert.equal(estop.payload.device_io.source, "wasm_io");
|
||||
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
|
||||
assert.equal(blocked.kind, "ERROR");
|
||||
assert.equal(send("IO_RECOVER", "io-recover").payload.device_io.source, "wasm_io");
|
||||
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
|
||||
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;
|
||||
assert.equal(firstHash, secondHash);
|
||||
console.log(JSON.stringify({ schema: "xyzac-trt-wasm-io-authority.v1", status: "verified", case: CASE, sources: ["wasm_io", "wasm_hal"], tool: 7, probe: true, estop_gate: "verified", state_hash: firstHash }));
|
||||
52
web/worker/test_xyzac_trt_wasm_program_authority.mjs
Normal file
52
web/worker/test_xyzac_trt_wasm_program_authority.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "xyzac-program-authority" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
const home = send("HOME", "home");
|
||||
assert.equal(home.payload.motion_authority, "wasm");
|
||||
assert.equal(home.payload.wasm_home_limit.state.homed, true);
|
||||
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 1, increment: 2 });
|
||||
assert.equal(jog.payload.motion_authority, "wasm");
|
||||
assert.equal(send("SIM_STEP", "jog-step", { cycles: 1 }).payload.motion_authority, "wasm");
|
||||
assert.equal(send("JOG_STOP", "jog-stop").payload.motion_authority, "wasm");
|
||||
const source = "G21 G90\nG1 X10 Y5 Z-2 A30 C45 F100\nM2\n";
|
||||
const loaded = send("PROGRAM_LOAD", "program-load", { source, path: "authority.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
assert.equal(loaded.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
const run = send("PROGRAM_RUN", "program-run");
|
||||
assert.equal(run.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(run.payload.task_authority, "wasm_task_canonical");
|
||||
const step = send("SIM_STEP", "program-step", { cycles: 1 });
|
||||
assert.equal(step.payload.motion_authority, "wasm");
|
||||
assert.ok(step.payload.program_steps.length >= 1);
|
||||
const wasmSegment = step.payload.wasm_motion;
|
||||
assert.equal(wasmSegment.status, "verified");
|
||||
assert.ok(wasmSegment.state.queue_depth >= 0);
|
||||
const blocked = send("PROGRAM_PAUSE", "pause");
|
||||
assert.equal(blocked.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
const resumed = send("PROGRAM_RESUME", "resume");
|
||||
assert.equal(resumed.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
const aborted = send("PROGRAM_ABORT", "abort");
|
||||
assert.equal(aborted.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(session.xyzacProgram.source, source);
|
||||
const checkpoint = send("CHECKPOINT", "checkpoint");
|
||||
assert.equal(checkpoint.kind, "CHECKPOINT_READY");
|
||||
const recovered = send("RECOVER", "recover", { checkpoint: checkpoint.payload.checkpoint });
|
||||
assert.equal(recovered.kind, "RECOVERY_STATUS");
|
||||
assert.equal(recovered.payload.status, "recovered");
|
||||
assert.equal(session.xyzacProgram.source, source);
|
||||
console.log(JSON.stringify({ schema: "xyzac-trt-wasm-program-authority.v1", status: "verified", case: CASE, program_source: "linuxcnc-wasm-sim_program", task_authority: "wasm_task_canonical", motion_authority: "wasm", state_hash: aborted.payload.panel_snapshot.state_hash }));
|
||||
57
web/worker/test_xyzac_workflow.mjs
Normal file
57
web/worker/test_xyzac_workflow.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "xyzac-workflow" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
|
||||
const source = "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nG1 X30 Y15 Z160 A40 C55 F100\nM2\n";
|
||||
const loaded = send("PROGRAM_LOAD", "program-load", { source, path: "workflow.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
const runWithout = send("PROGRAM_RUN", "run");
|
||||
assert.equal(runWithout.payload.task.state, "RUNNING");
|
||||
const first = send("SIM_STEP", "step-1", { cycles: 1 });
|
||||
assert.equal(first.payload.motion_authority, "wasm");
|
||||
const tickAfterFirst = first.simulation_tick;
|
||||
const paused = send("PROGRAM_PAUSE", "pause");
|
||||
assert.equal(paused.payload.task.state, "PAUSED");
|
||||
const cursorPaused = paused.payload.program.current_line;
|
||||
const pausedSnapshot = send("SNAPSHOT_REQUEST", "paused-snapshot");
|
||||
assert.equal(pausedSnapshot.payload.task.state, "PAUSED");
|
||||
assert.equal(pausedSnapshot.payload.panel_snapshot.program.current_line, cursorPaused);
|
||||
const resumed = send("PROGRAM_RESUME", "resume");
|
||||
assert.equal(resumed.payload.task.state, "RUNNING");
|
||||
const second = send("SIM_STEP", "step-2", { cycles: 1 });
|
||||
assert.ok(second.simulation_tick > tickAfterFirst);
|
||||
const activeBeforeAbort = second.payload.wasm_motion.state.active_id;
|
||||
const aborted = send("PROGRAM_ABORT", "abort");
|
||||
assert.equal(aborted.payload.task.state, "STOPPED");
|
||||
assert.equal(aborted.payload.panel_snapshot.wasm_motion.state.queue_depth, 0);
|
||||
assert.equal(aborted.payload.panel_snapshot.wasm_motion.state.active_id, 0);
|
||||
const restart = send("PROGRAM_RESTART", "restart");
|
||||
assert.equal(restart.payload.task.program_status, "loaded");
|
||||
assert.equal(restart.payload.program.path, "workflow.ngc");
|
||||
assert.equal(restart.payload.program.source_hash, loaded.payload.program.source_hash);
|
||||
const seek = send("PROGRAM_SEEK", "seek-line", { line: 3 });
|
||||
assert.equal(seek.payload.task.program_status, "paused");
|
||||
assert.equal(seek.payload.program.current_line, 3);
|
||||
const runLine = send("PROGRAM_RESUME", "run-from-line");
|
||||
assert.equal(runLine.payload.task.state, "RUNNING");
|
||||
const lineStep = send("SIM_STEP", "run-line-step", { cycles: 1 });
|
||||
assert.equal(lineStep.payload.motion_authority, "wasm");
|
||||
const checkpoint = send("CHECKPOINT", "checkpoint");
|
||||
assert.equal(checkpoint.kind, "CHECKPOINT_READY");
|
||||
const recovered = send("RECOVER", "recover", { checkpoint: checkpoint.payload.checkpoint });
|
||||
assert.equal(recovered.payload.status, "recovered");
|
||||
assert.equal(recovered.payload.state.program.source_text, source);
|
||||
assert.equal(session.ledger.reconcile("step-1").status, "committed");
|
||||
console.log(JSON.stringify({ schema: "xyzac-workflow-rtcp-05.v1", status: "verified", case: CASE, tick_after_first: tickAfterFirst, active_before_abort: activeBeforeAbort, pause_cursor: cursorPaused, restart_source_hash: restart.payload.program.source_hash, run_from_line: lineStep.payload.program.current_line, recovery: recovered.payload.status, final_state_hash: lineStep.payload.panel_snapshot.state_hash }));
|
||||
@@ -2,12 +2,13 @@ import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const HOME_LIMIT_SCHEMA = "sim_home_limit_state.v1";
|
||||
const KIN_TYPE_TRIVKINS = 1;
|
||||
const KIN_TYPE_XYZAC_TRT = 2;
|
||||
const KIN_CONFIG_SIZE = 1608;
|
||||
const KIN_SIZE = 1760;
|
||||
const HOME_LIMIT_SIZE = 184;
|
||||
const HOME_STATE_SIZE = 176;
|
||||
const HOME_STATE_OFFSET = 8;
|
||||
const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
|
||||
const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, x_rot_point: 40, y_offset: 72, z_offset: 80, tool_offset: 88, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
|
||||
const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168 });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
@@ -40,10 +41,13 @@ function finitePose(value) {
|
||||
}
|
||||
|
||||
export class WasmHomeLimit {
|
||||
constructor(core, { jointCount = 3, limits = {} } = {}) {
|
||||
constructor(core, { jointCount = 3, limits = {}, kinematicsType = KIN_TYPE_TRIVKINS, coordinates = "XYZ", offsets = {} } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.jointCount = jointCount;
|
||||
this.kinematicsType = kinematicsType;
|
||||
this.coordinates = String(coordinates).toUpperCase();
|
||||
this.offsets = { ...offsets };
|
||||
this.config = this.kinematics = this.homeLimit = this.state = this.joints = 0;
|
||||
this.stateBuffer = 0;
|
||||
this.limits = { x: [-500, 500], y: [-100, 100], z: [-240, 0.001], ...limits };
|
||||
@@ -60,18 +64,22 @@ export class WasmHomeLimit {
|
||||
this.core.HEAPU8.fill(0, this.stateBuffer, this.stateBuffer + HOME_STATE_SIZE);
|
||||
this.core.HEAPU8.fill(0, this.joints, this.joints + 9 * 8);
|
||||
const configView = new DataView(this.core.HEAPU8.buffer, this.config, KIN_CONFIG_SIZE);
|
||||
configView.setInt32(CONFIG_OFFSETS.type, KIN_TYPE_TRIVKINS, true);
|
||||
configView.setInt32(CONFIG_OFFSETS.type, kinematicsType, true);
|
||||
configView.setInt32(CONFIG_OFFSETS.mode, 0, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.joint_count, jointCount, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.kinematics_joint_count, jointCount, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.extra_joint_count, 0, true);
|
||||
for (const [index, axis] of ["X", "Y", "Z"].entries()) this.core.HEAPU8[this.config + CONFIG_OFFSETS.coordinates + index] = axis.charCodeAt(0);
|
||||
for (const [index, axis] of ["x", "y", "z"].entries()) {
|
||||
const limit = this.limits[axis] ?? [-1e9, 1e9];
|
||||
for (const [index, axis] of [...this.coordinates].entries()) this.core.HEAPU8[this.config + CONFIG_OFFSETS.coordinates + index] = axis.charCodeAt(0);
|
||||
for (const [index, axis] of [...this.coordinates].entries()) {
|
||||
const lowerAxis = axis.toLowerCase();
|
||||
const limit = this.limits[lowerAxis] ?? [-1e9, 1e9];
|
||||
this.core.HEAPU8[this.config + CONFIG_OFFSETS.limit_enabled + index] = 1;
|
||||
configView.setFloat64(CONFIG_OFFSETS.joint_min + index * 8, limit[0], true);
|
||||
configView.setFloat64(CONFIG_OFFSETS.joint_max + index * 8, limit[1], true);
|
||||
}
|
||||
for (const [key, offset] of Object.entries({ x_rot_point: CONFIG_OFFSETS.x_rot_point, y_offset: CONFIG_OFFSETS.y_offset, z_offset: CONFIG_OFFSETS.z_offset, tool_offset: CONFIG_OFFSETS.tool_offset })) {
|
||||
configView.setFloat64(offset, Number(this.offsets[key] ?? 0), true);
|
||||
}
|
||||
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
|
||||
if (initialized !== 0) throw new Error(`wasm_home_kinematics_init_${initialized}`);
|
||||
const homeInitialized = call(core, "sim_kinematics_home_limit_init", "number", ["number", "number", "number"], [this.homeLimit, this.kinematics, jointCount]);
|
||||
@@ -84,7 +92,7 @@ export class WasmHomeLimit {
|
||||
if (result !== 0) return { status: "error", code: `wasm_home_limit_read_${result}` };
|
||||
const ints = this.core.HEAPU32;
|
||||
const signed = this.core.HEAP32;
|
||||
const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 12)];
|
||||
const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 9 + this.jointCount)];
|
||||
return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42] } };
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ export class WasmMotionQueue {
|
||||
[...poseToArray(segment.start), ...poseToArray(segment.end), Number(segment.center?.x ?? 0), Number(segment.center?.y ?? 0), Number(segment.center?.z ?? 0), Number(segment.normal?.x ?? 0), Number(segment.normal?.y ?? 0), Number(segment.normal?.z ?? 0), Number(segment.velocity ?? 0), Number(segment.acceleration ?? 0)].forEach((value, index) => { doubles[base + index] = value; });
|
||||
ints[this.command / 4 + SEGMENT_INT_OFFSET] = Number(segment.motion_type ?? 1);
|
||||
ints[this.command / 4 + SEGMENT_INT_OFFSET + 1] = Number(segment.turn ?? 0);
|
||||
// sim_motion_command layout is segment (216 bytes), id (4), tag (4).
|
||||
// Keep the WASM switchkins mode in the tag field; the C queue assigns id.
|
||||
ints[this.command / 4 + SEGMENT_INT_OFFSET + 3] = Number(segment.kinematics_mode ?? segment.tag ?? 0);
|
||||
const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]);
|
||||
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}` };
|
||||
}
|
||||
@@ -153,13 +156,16 @@ export class WasmMotionQueue {
|
||||
const previous = this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize);
|
||||
const headerOffset = this.queueCapacity * this.commandCapacity;
|
||||
const stateOffset = headerOffset + QUEUE_HEADER_BYTES;
|
||||
const nextId = new DataView(previous.buffer, previous.byteOffset, previous.byteLength).getUint32(8, true);
|
||||
const previousView = new DataView(previous.buffer, previous.byteOffset, previous.byteLength);
|
||||
const nextId = previousView.getUint32(headerOffset + 8, true);
|
||||
this.reset();
|
||||
const bytes = new Uint8Array(this.queueSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
bytes.set(previous.subarray(16, 32), 16);
|
||||
view.setUint32(8, nextId, true);
|
||||
view.setUint32(12, current.state.tick, true);
|
||||
// sim_motion_queue stores commands first, followed by its 32-byte header.
|
||||
// Preserve cycle/velocity configuration from that actual header offset.
|
||||
bytes.set(previous.subarray(headerOffset + 16, headerOffset + QUEUE_HEADER_BYTES), headerOffset + 16);
|
||||
view.setUint32(headerOffset + 8, nextId, true);
|
||||
view.setUint32(headerOffset + 12, current.state.tick, true);
|
||||
bytes.set(previous.subarray(stateOffset, stateOffset + STATE_POSITION_BYTES), stateOffset);
|
||||
view.setFloat64(stateOffset + 72, 0, true);
|
||||
view.setFloat64(stateOffset + 80, 0, true);
|
||||
@@ -187,7 +193,7 @@ export class WasmMotionQueue {
|
||||
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
|
||||
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
|
||||
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
|
||||
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
|
||||
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
|
||||
}
|
||||
|
||||
exportState() {
|
||||
|
||||
246
web/worker/wasm-program-runtime.mjs
Normal file
246
web/worker/wasm-program-runtime.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const SCHEMA = "wasm_program_runtime.v1";
|
||||
const SEGMENT_BYTES = 216;
|
||||
const POSE_BYTES = 72;
|
||||
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
|
||||
const DEFAULT_RAPID_VELOCITY = 1000;
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const fn = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function pose(core, pointer) {
|
||||
const values = [...core.HEAPF64.subarray(pointer / 8, pointer / 8 + 9)];
|
||||
return Object.fromEntries(AXES.map((axis, index) => [axis, values[index]]));
|
||||
}
|
||||
|
||||
function segment(core, pointer) {
|
||||
const start = pose(core, pointer);
|
||||
const end = pose(core, pointer + 72);
|
||||
const cart = (offset) => ({ x: core.HEAPF64[pointer / 8 + offset / 8], y: core.HEAPF64[pointer / 8 + offset / 8 + 1], z: core.HEAPF64[pointer / 8 + offset / 8 + 2] });
|
||||
const ints = new DataView(core.HEAPU8.buffer, pointer, SEGMENT_BYTES);
|
||||
return { start, end, center: cart(144), normal: cart(168), velocity: core.HEAPF64[pointer / 8 + 24], acceleration: core.HEAPF64[pointer / 8 + 25], motion_type: ints.getInt32(208, true), turn: ints.getInt32(212, true) };
|
||||
}
|
||||
|
||||
function sampleSegment(core, segmentPointer, posePointer, value) {
|
||||
const turn = Math.max(1, Math.abs(Number(value.turn ?? 1)));
|
||||
const count = value.motion_type === 2 ? Math.max(32, Math.ceil(turn * 32)) : 1;
|
||||
const samples = [];
|
||||
for (let index = 0; index <= count; index += 1) {
|
||||
const result = call(core, "sim_posemath_segment_point", "number", ["number", "number", "number"], [segmentPointer, index / count, posePointer]);
|
||||
if (result !== 0) throw new Error(`wasm_program_segment_sample_${result}`);
|
||||
samples.push(pose(core, posePointer));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function readString(core, pointer, capacity) {
|
||||
const bytes = core.HEAPU8.subarray(pointer, pointer + capacity);
|
||||
const end = bytes.indexOf(0);
|
||||
return new TextDecoder().decode(bytes.subarray(0, end < 0 ? bytes.length : end));
|
||||
}
|
||||
|
||||
export class WasmProgramRuntime {
|
||||
constructor(core) {
|
||||
this.core = core;
|
||||
this.available = ["sim_program_size", "sim_program_init", "sim_program_step", "sim_program_canonical", "sim_program_add_subroutine", "sim_program_set_rapid_velocity"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.pointer = 0;
|
||||
this.motionPointer = 0;
|
||||
this.samplePointer = 0;
|
||||
this.samplePosePointer = 0;
|
||||
this.source = "";
|
||||
this.path = null;
|
||||
this.subroutines = [];
|
||||
this.rapidVelocity = 35;
|
||||
if (!this.available || typeof core?._malloc !== "function") return;
|
||||
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
|
||||
this.motionPointer = core._malloc(SEGMENT_BYTES);
|
||||
this.samplePointer = core._malloc(SEGMENT_BYTES);
|
||||
this.samplePosePointer = core._malloc(POSE_BYTES);
|
||||
if (!this.pointer || !this.motionPointer || !this.samplePointer || !this.samplePosePointer) throw new Error("wasm_program_allocation_failed");
|
||||
const result = call(core, "sim_program_init", "number", ["number"], [this.pointer]);
|
||||
if (result !== 0) throw new Error(`wasm_program_init_${result}`);
|
||||
}
|
||||
|
||||
invoke(name, args = [], errorPrefix = name) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_program_unavailable" };
|
||||
const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]);
|
||||
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result };
|
||||
}
|
||||
|
||||
load(source, path = null) {
|
||||
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
const pointer = this.core._malloc(bytes.byteLength);
|
||||
this.core.HEAPU8.set(bytes, pointer);
|
||||
try {
|
||||
const result = call(this.core, "sim_program_load", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_program_load_${result}` };
|
||||
const velocityResult = call(this.core, "sim_program_set_rapid_velocity", "number", ["number", "number"], [this.pointer, this.rapidVelocity]);
|
||||
if (velocityResult !== 0) return { status: "error", code: `wasm_program_rapid_velocity_${velocityResult}` };
|
||||
this.source = source;
|
||||
this.path = path;
|
||||
this.subroutines = [];
|
||||
return { status: "verified", program: this.snapshot() };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
|
||||
requiresSubroutines(source = this.source) {
|
||||
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
const pointer = this.core._malloc(bytes.byteLength);
|
||||
this.core.HEAPU8.set(bytes, pointer);
|
||||
try {
|
||||
const result = call(this.core, "sim_program_requires_subroutines", "number", ["number", "number"], [pointer, bytes.byteLength]);
|
||||
if (result < 0) return { status: "error", code: `wasm_program_requires_subroutines_${result}` };
|
||||
return { status: "verified", required: result === 1 };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
|
||||
preview() {
|
||||
if (!this.source) return { status: "error", code: "program_preview_source_missing" };
|
||||
const temporary = new WasmProgramRuntime(this.core);
|
||||
const segments = [];
|
||||
try {
|
||||
const loaded = temporary.load(this.source, this.path);
|
||||
if (loaded.status !== "verified") return loaded;
|
||||
temporary.setRapidVelocity(this.rapidVelocity);
|
||||
for (const subroutine of this.subroutines) {
|
||||
const added = temporary.addSubroutine(subroutine.name, subroutine.source);
|
||||
if (added.status !== "verified") return added;
|
||||
}
|
||||
const started = temporary.start();
|
||||
if (started.status !== "verified") return started;
|
||||
const limit = Math.min(100000, Math.max(1, temporary.snapshot().line_count * 8 + 1));
|
||||
let kinematicsMode = 0;
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const step = temporary.step();
|
||||
if (step.status !== "verified") return { status: "error", code: step.code, line: temporary.snapshot().current_line, segments };
|
||||
if (step.has_control && [428, 429, 430].includes(step.control_code)) kinematicsMode = { 428: 1, 429: 0, 430: 2 }[step.control_code];
|
||||
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
|
||||
if ([4, 5, 6].includes(temporary.snapshot().status)) break;
|
||||
}
|
||||
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
|
||||
} finally { temporary.destroy(); }
|
||||
}
|
||||
|
||||
start() { return this.invoke("sim_program_start", [], "program_start"); }
|
||||
setRapidVelocity(velocity) {
|
||||
if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" };
|
||||
this.rapidVelocity = Number(velocity);
|
||||
const result = call(this.core, "sim_program_set_rapid_velocity", "number", ["number", "number"], [this.pointer, this.rapidVelocity]);
|
||||
if (result !== 0) throw new Error(`wasm_program_set_rapid_velocity_${result}`);
|
||||
return { status: "verified", command: "sim_program_set_rapid_velocity" };
|
||||
}
|
||||
addSubroutine(name, source) {
|
||||
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_subroutine_invalid" };
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
const pointer = this.core._malloc(bytes.byteLength);
|
||||
this.core.HEAPU8.set(bytes, pointer);
|
||||
try {
|
||||
const code = call(this.core, "sim_program_add_subroutine", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]);
|
||||
if (code !== 0) return { status: "error", code: `wasm_program_add_subroutine_${code}`, result: code };
|
||||
this.subroutines.push({ name: String(name ?? "subroutine"), source });
|
||||
return { status: "verified", command: "sim_program_add_subroutine", name: String(name ?? "subroutine") };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
pause() { return this.invoke("sim_program_pause", [], "program_pause"); }
|
||||
resume() { return this.invoke("sim_program_resume", [], "program_resume"); }
|
||||
abort() { return this.invoke("sim_program_abort", [], "program_abort"); }
|
||||
seek(line) { return this.invoke("sim_program_seek", [line], "program_seek"); }
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
|
||||
if (value.source_hash && value.source_hash !== fnv1a64(this.source)) throw new Error("wasm_program_restore_source_hash");
|
||||
const subroutines = [...this.subroutines];
|
||||
const loaded = this.load(this.source, this.path);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
for (const subroutine of subroutines) {
|
||||
const added = this.addSubroutine(subroutine.name, subroutine.source);
|
||||
if (added.status !== "verified") throw new Error(added.code);
|
||||
}
|
||||
if (value.current_line > 0) {
|
||||
const seek = this.seek(value.current_line);
|
||||
if (seek.status !== "verified") throw new Error(seek.code);
|
||||
}
|
||||
const status = value.program_status ?? ({ 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[value.status] ?? "loaded");
|
||||
if (status === "running") {
|
||||
const resumed = this.resume();
|
||||
if (resumed.status !== "verified") {
|
||||
const started = this.start();
|
||||
if (started.status !== "verified") throw new Error(started.code);
|
||||
}
|
||||
} else if (status === "completed") {
|
||||
this.start();
|
||||
for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
||||
} else if (status === "aborted") {
|
||||
const aborted = this.abort();
|
||||
if (aborted.status !== "verified") throw new Error(aborted.code);
|
||||
}
|
||||
if (value.has_motion === 0) this.clearMotion();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
executeBlock(block) {
|
||||
const bytes = new TextEncoder().encode(String(block ?? ""));
|
||||
const pointer = this.core._malloc(bytes.byteLength);
|
||||
this.core.HEAPU8.set(bytes, pointer);
|
||||
try {
|
||||
const result = call(this.core, "sim_program_execute_block", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_program_execute_${result}` };
|
||||
return { status: "verified", program: this.snapshot(), motion: this.readMotion() };
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
|
||||
readMotion() {
|
||||
const state = this.snapshot();
|
||||
if (!state.has_motion) return null;
|
||||
const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]);
|
||||
if (result !== 0) return null;
|
||||
const value = segment(this.core, this.motionPointer);
|
||||
// LinuxCNC rapid blocks do not carry an F word. The trajectory queue
|
||||
// requires a positive velocity, so use the simulation's configured rapid
|
||||
// rate for that WASM-produced linear segment.
|
||||
if (state.motion_mode === 0 && value.motion_type === 1 && value.velocity <= 0) value.velocity = DEFAULT_RAPID_VELOCITY;
|
||||
value.samples = sampleSegment(this.core, this.motionPointer, this.samplePosePointer, value);
|
||||
value.samples_source = "linuxcnc-wasm-sim_posemath_segment_point";
|
||||
value.canonical_motion_mode = state.motion_mode;
|
||||
value.kind = value.motion_type === 2 && Math.abs(Number(value.end?.z ?? 0) - Number(value.start?.z ?? 0)) > 1e-12 ? "helix" : value.motion_type === 2 ? "arc" : state.motion_mode === 0 ? "rapid" : value.motion_type === 1 ? "feed" : "traverse";
|
||||
if (value.kind === "helix") {
|
||||
value.helix = true;
|
||||
value.radius = Math.hypot(Number(value.start?.x ?? 0) - Number(value.center?.x ?? 0), Number(value.start?.y ?? 0) - Number(value.center?.y ?? 0));
|
||||
value.turns = Math.abs(Number(value.turn ?? 1));
|
||||
value.axial_delta = Number(value.end?.z ?? 0) - Number(value.start?.z ?? 0);
|
||||
}
|
||||
call(this.core, "sim_program_clear_motion", "number", ["number"], [this.pointer]);
|
||||
return { ...value, source_line: state.current_line };
|
||||
}
|
||||
|
||||
clearMotion() { return this.invoke("sim_program_clear_motion", [], "program_clear_motion"); }
|
||||
|
||||
step() {
|
||||
const result = this.invoke("sim_program_step", [], "program_step");
|
||||
if (result.status !== "verified") return result;
|
||||
const state = this.snapshot();
|
||||
return { status: "verified", program_status: state.status, line: state.current_line, control_code: state.control_code ?? 0, has_control: state.has_control === 1, program: state, motion: this.readMotion() };
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
if (!this.available) return { schema: SCHEMA, status: "blocked", code: "wasm_program_unavailable" };
|
||||
const required = call(this.core, "sim_program_canonical", "number", ["number", "number", "number"], [this.pointer, 0, 0]);
|
||||
const buffer = this.core._malloc(required);
|
||||
try {
|
||||
call(this.core, "sim_program_canonical", "number", ["number", "number", "number"], [this.pointer, buffer, required]);
|
||||
const state = JSON.parse(readString(this.core, buffer, required));
|
||||
const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]);
|
||||
const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle";
|
||||
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
|
||||
} finally { this.core._free(buffer); }
|
||||
}
|
||||
|
||||
destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; }
|
||||
}
|
||||
111
web/worker/wasm-xyzac-device-io.mjs
Normal file
111
web/worker/wasm-xyzac-device-io.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const DEVICE_SCHEMA = "wasm_xyzac_device_io.v1";
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const fn = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function readString(core, pointer, capacity) {
|
||||
const bytes = core.HEAPU8.subarray(pointer, pointer + capacity);
|
||||
const end = bytes.indexOf(0);
|
||||
return new TextDecoder().decode(bytes.subarray(0, end < 0 ? bytes.length : end));
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexToBytes(value) {
|
||||
if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error("wasm_xyzac_device_io_state_bytes_invalid");
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function operationNumber(value) {
|
||||
if (Number.isSafeInteger(value) && value > 0) return value;
|
||||
const text = String(value ?? "device");
|
||||
let hash = 2166136261;
|
||||
for (const byte of new TextEncoder().encode(text)) { hash ^= byte; hash = Math.imul(hash, 16777619); }
|
||||
return (hash >>> 0) || 1;
|
||||
}
|
||||
|
||||
export class WasmXyzacDeviceIo {
|
||||
constructor(core, { encoderCount = 2 } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_device_io_size", "sim_device_init", "sim_device_canonical", "sim_device_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.pointer = 0;
|
||||
if (!this.available || typeof core?._malloc !== "function") return;
|
||||
const size = call(core, "sim_device_io_size", "number", [], []);
|
||||
this.pointer = core._malloc(size);
|
||||
if (!this.pointer) throw new Error("wasm_xyzac_device_io_allocation_failed");
|
||||
const result = call(core, "sim_device_init", "number", ["number", "number", "number"], [this.pointer, encoderCount, 0.01]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_device_io_init_${result}`);
|
||||
}
|
||||
|
||||
invoke(name, args = []) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_device_io_unavailable" };
|
||||
const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]);
|
||||
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result };
|
||||
}
|
||||
|
||||
canonical() {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_device_io_unavailable" };
|
||||
const required = call(this.core, "sim_device_canonical", "number", ["number", "number", "number"], [this.pointer, 0, 0]);
|
||||
const buffer = this.core._malloc(required);
|
||||
try { call(this.core, "sim_device_canonical", "number", ["number", "number", "number"], [this.pointer, buffer, required]); return JSON.parse(readString(this.core, buffer, required)); }
|
||||
finally { this.core._free(buffer); }
|
||||
}
|
||||
|
||||
export() {
|
||||
const state = this.canonical();
|
||||
if (state.status === "blocked") return { schema: DEVICE_SCHEMA, source: "wasm_device_io", ...state };
|
||||
const unsigned = { schema: DEVICE_SCHEMA, source: "wasm_device_io", state, wasm_state_hash: this.stateHash(), state_bytes: bytesToHex(this.core.HEAPU8.slice(this.pointer, this.pointer + call(this.core, "sim_device_io_size", "number", [], []))) };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || !value.state_bytes || !this.available) throw new Error("wasm_xyzac_device_io_restore_unavailable");
|
||||
const bytes = hexToBytes(value.state_bytes);
|
||||
const size = call(this.core, "sim_device_io_size", "number", [], []);
|
||||
if (bytes.length !== size) throw new Error("wasm_xyzac_device_io_restore_layout");
|
||||
this.core.HEAPU8.set(bytes, this.pointer);
|
||||
const actual = this.canonical();
|
||||
if (value.state && fnv1a64(actual) !== fnv1a64(value.state)) throw new Error("wasm_xyzac_device_io_restore_state_mismatch");
|
||||
return this.export();
|
||||
}
|
||||
|
||||
stateHash() {
|
||||
if (!this.available) return null;
|
||||
const hash = call(this.core, "sim_device_state_hash", "number", ["number"], [this.pointer]);
|
||||
return `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`;
|
||||
}
|
||||
|
||||
command(kind, payload = {}, operationId = null) {
|
||||
if (kind === "ENCODER_SET") {
|
||||
const channel = payload.channel ?? 0;
|
||||
if (payload.speed_rps !== undefined || payload.speed !== undefined) return this.invoke("sim_device_encoder_set_speed", [channel, payload.speed_rps ?? payload.speed]);
|
||||
if (payload.ppr !== undefined || payload.scale !== undefined) return this.invoke("sim_device_encoder_set_config", [channel, payload.ppr ?? 1000, payload.scale ?? 1]);
|
||||
return this.invoke("sim_device_encoder_set_index_enable", [channel, payload.index_enable === true ? 1 : 0]);
|
||||
}
|
||||
if (kind === "COOLANT_COMMAND") return this.invoke("sim_device_coolant_command", [payload.mist ? 1 : 0, payload.flood ? 1 : 0, payload.lube ? 1 : 0]);
|
||||
if (kind === "COOLANT_FAULT") return this.invoke("sim_device_coolant_set_fault", [payload.fault === true ? 1 : 0]);
|
||||
if (kind === "DOOR_SET") return this.invoke("sim_device_door_set", [payload.open === true ? 1 : 0]);
|
||||
if (kind === "INTERLOCK_SET") return this.invoke("sim_device_interlock_set", [payload.input_ok === false ? 0 : 1]);
|
||||
if (kind === "INTERLOCK_RESET") return this.invoke("sim_device_interlock_reset");
|
||||
if (kind === "DEVICE_ESTOP") return this.invoke("sim_device_estop_set", [payload.active === true ? 1 : 0]);
|
||||
if (kind === "DEVICE_ACK_ISSUE") return this.invoke("sim_device_ack_issue", [operationNumber(operationId ?? payload.operation_id), payload.timeout_ticks ?? 10]);
|
||||
if (kind === "DEVICE_ACK") return this.invoke("sim_device_ack_ack", [operationNumber(payload.operation_id)]);
|
||||
if (kind === "DEVICE_ACK_ABORT") return this.invoke("sim_device_ack_abort", [operationNumber(payload.operation_id)]);
|
||||
if (kind === "IO_STEP") return this.invoke("sim_device_step", [payload.ticks ?? 1]);
|
||||
return { status: "error", code: "wasm_xyzac_device_io_command_unsupported", command: kind };
|
||||
}
|
||||
|
||||
destroy() { if (this.pointer && typeof this.core?._free === "function") this.core._free(this.pointer); this.pointer = 0; }
|
||||
}
|
||||
170
web/worker/wasm-xyzac-io.mjs
Normal file
170
web/worker/wasm-xyzac-io.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const IO_SCHEMA = "wasm_xyzac_io.v1";
|
||||
const HAL_SCHEMA = "wasm_xyzac_hal.v1";
|
||||
const POSE_BYTES = 72;
|
||||
const HAL_WRITE_ALLOWLIST = new Set(["motion.probe-input", "iocontrol.0.tool-prepared", "iocontrol.0.tool-changed", "spindle.0.speed-in", "spindle.0.at-speed"]);
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const fn = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function callString(core, name, returnType, argTypes, args) {
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing string-capable core export: ${name}`);
|
||||
}
|
||||
|
||||
function readString(core, pointer, capacity) {
|
||||
const bytes = core.HEAPU8.subarray(pointer, pointer + capacity);
|
||||
const end = bytes.indexOf(0);
|
||||
return new TextDecoder().decode(bytes.subarray(0, end < 0 ? bytes.length : end));
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexToBytes(value) {
|
||||
if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error("wasm_xyzac_io_state_bytes_invalid");
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export class WasmXyzacIo {
|
||||
constructor(core, { spindleCount = 2, toolCapacity = 1001 } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_io_size", "sim_io_init", "sim_io_canonical", "sim_io_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.pointer = 0;
|
||||
this.posePointer = 0;
|
||||
this.ioSize = 0;
|
||||
this.spindleCount = spindleCount;
|
||||
this.toolCapacity = toolCapacity;
|
||||
this.lastError = 0;
|
||||
if (!this.available || typeof core?._malloc !== "function") return;
|
||||
this.ioSize = call(core, "sim_io_size", "number", [], []);
|
||||
this.pointer = core._malloc(this.ioSize);
|
||||
this.posePointer = core._malloc(POSE_BYTES);
|
||||
if (!this.pointer || !this.posePointer) throw new Error("wasm_xyzac_io_allocation_failed");
|
||||
const result = call(core, "sim_io_init", "number", ["number", "number", "number", "number", "number", "number"], [this.pointer, spindleCount, toolCapacity, 0.01, 600, 5]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_io_init_${result}`);
|
||||
}
|
||||
|
||||
invoke(name, args = []) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_io_unavailable" };
|
||||
const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]);
|
||||
this.lastError = result;
|
||||
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result };
|
||||
}
|
||||
|
||||
canonical() {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_io_unavailable" };
|
||||
const required = call(this.core, "sim_io_canonical", "number", ["number", "number", "number"], [this.pointer, 0, 0]);
|
||||
const buffer = this.core._malloc(required);
|
||||
try {
|
||||
call(this.core, "sim_io_canonical", "number", ["number", "number", "number"], [this.pointer, buffer, required]);
|
||||
return JSON.parse(readString(this.core, buffer, required));
|
||||
} finally { this.core._free(buffer); }
|
||||
}
|
||||
|
||||
stateHash() {
|
||||
if (!this.available) return null;
|
||||
const hash = call(this.core, "sim_io_state_hash", "number", ["number"], [this.pointer]);
|
||||
return `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`;
|
||||
}
|
||||
|
||||
export() {
|
||||
const state = this.canonical();
|
||||
if (state.status === "blocked") return { schema: IO_SCHEMA, source: "wasm_io", ...state };
|
||||
const unsigned = { schema: IO_SCHEMA, source: "wasm_io", state, state_hash: this.stateHash(), state_bytes: bytesToHex(this.core.HEAPU8.slice(this.pointer, this.pointer + this.ioSize)) };
|
||||
return { ...unsigned, projection_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || !value.state_bytes || !this.available) throw new Error("wasm_xyzac_io_restore_unavailable");
|
||||
const bytes = hexToBytes(value.state_bytes);
|
||||
if (bytes.length !== this.ioSize) throw new Error("wasm_xyzac_io_restore_layout");
|
||||
this.core.HEAPU8.set(bytes, this.pointer);
|
||||
const actual = this.canonical();
|
||||
if (value.state && fnv1a64(actual) !== fnv1a64(value.state)) throw new Error("wasm_xyzac_io_restore_state_mismatch");
|
||||
return this.export();
|
||||
}
|
||||
|
||||
halExport() {
|
||||
if (!this.available) return { schema: HAL_SCHEMA, source: "wasm_hal", status: "blocked", code: "wasm_hal_unavailable" };
|
||||
const tick = call(this.core, "sim_hal_tick", "number", [], []);
|
||||
const unsigned = {
|
||||
schema: HAL_SCHEMA,
|
||||
source: "wasm_hal",
|
||||
status: "verified",
|
||||
tick,
|
||||
pin_count: call(this.core, "sim_hal_pin_count", "number", [], []),
|
||||
param_count: call(this.core, "sim_hal_param_count", "number", [], []),
|
||||
thread_count: call(this.core, "sim_hal_thread_count", "number", [], []),
|
||||
function_count: call(this.core, "sim_hal_function_count", "number", [], []),
|
||||
component_count: call(this.core, "sim_hal_component_count", "number", [], []),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
halValue(name, value, type = 4, param = false) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_hal_unavailable" };
|
||||
if (param || !HAL_WRITE_ALLOWLIST.has(name)) return { status: "error", code: "wasm_hal_write_denied", name, target: param ? "param" : "pin" };
|
||||
const result = callString(this.core, param ? "sim_hal_set_param_scalar" : "sim_hal_set_pin_scalar", "number", ["string", "number", "number"], [name, type, Number(value)]);
|
||||
return result === 0 ? { status: "verified", command: param ? "HAL_SET_PARAM" : "HAL_SET_PIN" } : { status: "error", code: `wasm_hal_set_${result}`, result };
|
||||
}
|
||||
|
||||
halCommand(payload = {}) {
|
||||
if (payload.action === "thread") return callString(this.core, "sim_hal_register_thread", "number", ["string", "number"], [payload.name ?? "servo-thread", payload.period_ticks ?? 1]) === 0 ? { status: "verified", command: "HAL_THREAD" } : { status: "error", code: "wasm_hal_thread" };
|
||||
if (payload.action === "component") return callString(this.core, "sim_hal_register_component", "number", ["string", "string", "string"], [payload.instance ?? payload.name, payload.type ?? payload.module, payload.thread ?? "servo-thread"]) === 0 ? { status: "verified", command: "HAL_COMPONENT" } : { status: "error", code: "wasm_hal_component" };
|
||||
if (payload.action === "connect") return callString(this.core, "sim_hal_connect", "number", ["string", "string"], [payload.source, payload.target]) === 0 ? { status: "verified", command: "HAL_CONNECT" } : { status: "error", code: "wasm_hal_connect" };
|
||||
if (payload.action === "step") {
|
||||
const result = call(this.core, "sim_hal_step", "number", ["number"], [payload.ticks ?? payload.tick ?? 1]);
|
||||
return result === 0 ? { status: "verified", command: "HAL_STEP" } : { status: "error", code: `wasm_hal_step_${result}` };
|
||||
}
|
||||
return { status: "error", code: "wasm_hal_command_unsupported" };
|
||||
}
|
||||
|
||||
command(kind, payload = {}) {
|
||||
if (kind === "SPINDLE_COMMAND") return this.invoke("sim_io_spindle_command", [payload.spindle ?? 0, payload.rpm ?? payload.speed ?? 0, payload.enabled === false ? 0 : 1]);
|
||||
if (kind === "SPINDLE_FAULT") return this.invoke("sim_io_spindle_set_fault", [payload.spindle ?? 0, payload.fault === true ? 1 : 0]);
|
||||
if (kind === "SPINDLE_INHIBIT") return this.invoke("sim_io_spindle_set_inhibit", [payload.spindle ?? 0, payload.inhibit === true ? 1 : 0]);
|
||||
if (kind === "SPINDLE_ORIENT") return this.invoke("sim_io_spindle_orient", [payload.spindle ?? 0, payload.angle ?? 0, payload.mode ?? 0]);
|
||||
if (kind === "SPINDLE_ORIENTED") return this.invoke("sim_io_spindle_set_oriented", [payload.spindle ?? 0, payload.oriented === true ? 1 : 0, payload.fault ?? 0]);
|
||||
if (kind === "TOOL_PREPARE") return this.invoke("sim_io_tool_prepare", [payload.tool ?? payload.tool_number ?? 0, payload.pocket ?? payload.pocket_number ?? 0]);
|
||||
if (kind === "TOOL_PREPARED") return this.invoke("sim_io_tool_set_prepared", [payload.prepared === false ? 0 : 1]);
|
||||
if (kind === "TOOL_CHANGE") return this.invoke("sim_io_tool_change");
|
||||
if (kind === "TOOL_CHANGED") return this.invoke("sim_io_tool_set_changed", [payload.changed === false ? 0 : 1]);
|
||||
if (kind === "TOOL_ABORT") return this.invoke("sim_io_tool_abort", [payload.reason ?? 0]);
|
||||
if (kind === "TOOL_FAULT") return this.invoke("sim_io_tool_set_fault", [payload.fault === true ? 1 : 0, payload.reason ?? 0]);
|
||||
if (kind === "TOOL_CLEAR_FAULT") return this.invoke("sim_io_tool_clear_fault");
|
||||
if (kind === "PROBE_CONFIG") {
|
||||
const result = this.invoke("sim_io_probe_arm", [payload.probe_type ?? payload.type ?? 0]);
|
||||
return result;
|
||||
}
|
||||
if (kind === "ESTOP" || kind === "DEVICE_ESTOP") return this.invoke("sim_io_set_estop", [payload.active === true ? 1 : 0]);
|
||||
if (kind === "IO_RECOVER" || kind === "INTERLOCK_RESET") return this.invoke("sim_io_recover");
|
||||
if (kind === "IO_STEP") return this.invoke("sim_io_step", [payload.ticks ?? 1]);
|
||||
if (kind === "COOLANT_COMMAND") return { status: "error", code: "wasm_device_io_required", detail: "coolant uses sim_device_io authority" };
|
||||
return { status: "error", code: "wasm_xyzac_io_command_unsupported", command: kind };
|
||||
}
|
||||
|
||||
probeUpdate(pose, input = 1) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_io_unavailable" };
|
||||
const values = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(pose?.[axis] ?? 0));
|
||||
this.core.HEAPF64.set(values, this.posePointer / 8);
|
||||
return this.invoke("sim_io_probe_update", [this.posePointer, input ? 1 : 0]);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const pointer of [this.pointer, this.posePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer);
|
||||
this.pointer = this.posePointer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export { HAL_WRITE_ALLOWLIST };
|
||||
181
web/worker/wasm-xyzac-kinematics.mjs
Normal file
181
web/worker/wasm-xyzac-kinematics.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const CONFIG_SIZE = 1608;
|
||||
const KINEMATICS_SIZE = 1760;
|
||||
const SWITCH_SIZE = 512;
|
||||
const RESULT_INFO_SIZE = 32;
|
||||
const POSE_SIZE = 72;
|
||||
const JOINT_BYTES = 9 * 8;
|
||||
const CONFIG = Object.freeze({
|
||||
type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16,
|
||||
coordinates: 20, x_rot_point: 40, y_rot_point: 48, z_rot_point: 56,
|
||||
x_offset: 64, y_offset: 72, z_offset: 80, tool_offset: 88,
|
||||
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 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] });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const direct = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof direct === "function") return direct(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function pose(core, pointer) {
|
||||
const doubles = core.HEAPF64.subarray(pointer / 8, pointer / 8 + 9);
|
||||
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 configFor(core, pointer, type, options) {
|
||||
core.HEAPU8.fill(0, pointer, pointer + CONFIG_SIZE);
|
||||
const view = new DataView(core.HEAPU8.buffer, pointer, CONFIG_SIZE);
|
||||
view.setInt32(CONFIG.type, type, true);
|
||||
view.setInt32(CONFIG.mode, 0, true);
|
||||
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()) {
|
||||
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 })) {
|
||||
view.setFloat64(offset, Number(options[key] ?? 0), true);
|
||||
}
|
||||
}
|
||||
|
||||
function resultCode(operation, result) {
|
||||
return { status: "error", code: `wasm_xyzac_${operation}_${result}`, 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.available = ["sim_kinematics_init", "sim_kinematics_switch_init", "sim_kinematics_switch_register", "sim_kinematics_switch_select", "sim_kinematics_switch_forward", "sim_kinematics_switch_inverse", "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]]) {
|
||||
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);
|
||||
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 });
|
||||
this.pointers.push(config, kinematics);
|
||||
}
|
||||
this.switchPointer = core._malloc(SWITCH_SIZE);
|
||||
this.resultPointer = core._malloc(RESULT_INFO_SIZE);
|
||||
this.posePointer = core._malloc(POSE_SIZE);
|
||||
this.jointPointer = core._malloc(JOINT_BYTES);
|
||||
if (![this.switchPointer, this.resultPointer, this.posePointer, this.jointPointer].every(Boolean)) throw new Error("wasm_xyzac_allocation_failed");
|
||||
this.pointers.push(this.switchPointer, this.resultPointer, this.posePointer, this.jointPointer);
|
||||
for (const [pointer, size] of [[this.switchPointer, SWITCH_SIZE], [this.resultPointer, RESULT_INFO_SIZE], [this.posePointer, POSE_SIZE], [this.jointPointer, JOINT_BYTES]]) core.HEAPU8.fill(0, pointer, pointer + size);
|
||||
this.initializeSwitch();
|
||||
}
|
||||
|
||||
initializeSwitch() {
|
||||
const initialized = call(this.core, "sim_kinematics_switch_init", "number", ["number", "number", "number"], [this.switchPointer, MODE.identity, 0]);
|
||||
if (initialized !== 0) throw new Error(`wasm_xyzac_switch_init_${initialized}`);
|
||||
for (const [mode, module] of this.modules) {
|
||||
const registered = call(this.core, "sim_kinematics_switch_register", "number", ["number", "number", "number", "number"], [this.switchPointer, mode, module.kinematics, 0]);
|
||||
if (registered !== 0) throw new Error(`wasm_xyzac_switch_register_${mode}_${registered}`);
|
||||
}
|
||||
}
|
||||
|
||||
get activeMode() {
|
||||
if (!this.available) return MODE.identity;
|
||||
return call(this.core, "sim_kinematics_switch_active", "number", ["number"], [this.switchPointer]);
|
||||
}
|
||||
|
||||
setJoints(joints) {
|
||||
if (!Array.isArray(joints) || joints.length < 5) throw new Error("wasm_xyzac_joints_invalid");
|
||||
this.core.HEAPF64.set(joints.slice(0, 9).map(Number), this.jointPointer / 8);
|
||||
}
|
||||
|
||||
setPose(value) {
|
||||
if (!value || typeof value !== "object") throw new Error("wasm_xyzac_pose_invalid");
|
||||
this.core.HEAPF64.set(AXES.map((axis) => Number(value[axis] ?? 0)), this.posePointer / 8);
|
||||
}
|
||||
|
||||
forward(joints = [0, 0, 0, 0, 0]) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_unavailable" };
|
||||
this.setJoints(joints);
|
||||
const result = call(this.core, "sim_kinematics_switch_forward", "number", ["number", "number", "number", "number", "number"], [this.switchPointer, this.jointPointer, 5, this.posePointer, this.resultPointer]);
|
||||
if (result !== 0) return resultCode("forward", result);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
inverse(currentPose) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_xyzac_unavailable" };
|
||||
this.setPose(currentPose);
|
||||
const result = call(this.core, "sim_kinematics_switch_inverse", "number", ["number", "number", "number", "number", "number"], [this.switchPointer, this.posePointer, this.jointPointer, 5, this.resultPointer]);
|
||||
if (result !== 0) return resultCode("inverse", result);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
select(mode, joints = [0, 0, 0, 0, 0]) {
|
||||
if (![MODE.identity, MODE.xyzac, MODE.userk].includes(mode)) return { status: "error", code: "wasm_xyzac_invalid_mode" };
|
||||
this.setJoints(joints);
|
||||
const result = call(this.core, "sim_kinematics_switch_select", "number", ["number", "number", "number", "number", "number", "number"], [this.switchPointer, mode, this.jointPointer, 5, this.posePointer, this.resultPointer]);
|
||||
if (result !== 0) return resultCode("switch", result);
|
||||
this.lastCommand = `M${mode === MODE.xyzac ? 428 : mode === MODE.identity ? 429 : 430}`;
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
restoreState(state) {
|
||||
if (!state || state.schema !== "wasm-xyzac-kinematics.v1" || ![MODE.identity, MODE.xyzac, MODE.userk].includes(state.active_type) || !Array.isArray(state.joints) || state.joints.length !== 5) throw new Error("wasm_xyzac_restore_state_invalid");
|
||||
this.initializeSwitch();
|
||||
this.setPose(state.pose);
|
||||
this.setJoints(state.joints);
|
||||
if (state.active_type !== MODE.identity) {
|
||||
let result = call(this.core, "sim_kinematics_switch_begin", "number", ["number", "number"], [this.switchPointer, state.active_type]);
|
||||
if (result === 0) result = call(this.core, "sim_kinematics_switch_commit", "number", ["number", "number", "number", "number", "number"], [this.switchPointer, this.jointPointer, 5, this.posePointer, this.resultPointer]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_restore_switch_${result}`);
|
||||
} else {
|
||||
const result = call(this.core, "sim_kinematics_switch_inverse", "number", ["number", "number", "number", "number", "number"], [this.switchPointer, this.posePointer, this.jointPointer, 5, this.resultPointer]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_restore_inverse_${result}`);
|
||||
}
|
||||
this.setJoints(state.joints);
|
||||
this.setPose(state.pose);
|
||||
const view = new DataView(this.core.HEAPU8.buffer, this.switchPointer, SWITCH_SIZE);
|
||||
view.setUint8(SWITCH.switching, 0);
|
||||
view.setInt32(SWITCH.active_mode, state.active_type, true);
|
||||
view.setInt32(SWITCH.pending_mode, state.active_type, true);
|
||||
view.setUint32(SWITCH.generation, Number(state.generation ?? 0), true);
|
||||
view.setUint32(SWITCH.switch_count, Number(state.switch_count ?? state.generation ?? 0), true);
|
||||
view.setUint32(SWITCH.abort_count, Number(state.abort_count ?? 0), true);
|
||||
this.lastCommand = state.last_command ?? null;
|
||||
const restored = this.snapshot();
|
||||
if (restored.active_type !== state.active_type) throw new Error("wasm_xyzac_restore_projection_mismatch");
|
||||
return restored;
|
||||
}
|
||||
|
||||
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 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 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) };
|
||||
}
|
||||
|
||||
destroy() { for (const pointer of this.pointers) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointers = []; }
|
||||
}
|
||||
|
||||
export { MODE as XYZAC_MODES };
|
||||
54
web/worker/xyzac-command-ledger.mjs
Normal file
54
web/worker/xyzac-command-ledger.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import { OperationLedger, LEDGER_SCHEMA, LEDGER_PHASES } from "./operation-ledger.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
export const XYZAC_COMMAND_LEDGER_SCHEMA = "xyzac-command-ledger.v1";
|
||||
const TERMINAL_PHASES = new Set(["committed", "aborted", "failed"]);
|
||||
|
||||
export class XyzacCommandLedger extends OperationLedger {
|
||||
constructor(entries = []) {
|
||||
super(entries);
|
||||
this.scope = "selected_xyzac_simulation";
|
||||
}
|
||||
|
||||
prepare(input) {
|
||||
if (["HELLO", "CAPABILITIES", "SNAPSHOT_REQUEST", "CANCEL"].includes(input?.kind)) return { status: "untracked", replay: false, entry: null };
|
||||
const result = super.prepare(input);
|
||||
if (result.entry) {
|
||||
result.entry.scope = this.scope;
|
||||
result.entry.intent_hash = result.entry.intent_hash ?? fnv1a64({ kind: input.kind, payload: input.payload ?? {}, operation_id: input.operation_id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
acknowledge(operationId, response) {
|
||||
return this.verify(operationId, response);
|
||||
}
|
||||
|
||||
result(operationId, response, seq) {
|
||||
this.acknowledge(operationId, response);
|
||||
return this.commit(operationId, seq);
|
||||
}
|
||||
|
||||
pending() {
|
||||
return [...this.entries.values()]
|
||||
.filter((entry) => !TERMINAL_PHASES.has(entry.phase))
|
||||
.sort((left, right) => left.seq - right.seq)
|
||||
.map((entry) => structuredClone(entry));
|
||||
}
|
||||
|
||||
reconcileAll() {
|
||||
return this.pending().map((entry) => ({ operation_id: entry.operation_id, status: entry.phase, entry }));
|
||||
}
|
||||
|
||||
timeoutReconcile(operationId) {
|
||||
const reconciliation = this.reconcile(operationId);
|
||||
return { status: "reconcile_required", operation_id: operationId, retry_allowed: false, reconciliation };
|
||||
}
|
||||
|
||||
audit() {
|
||||
const exported = super.export();
|
||||
const pending = this.pending();
|
||||
const unsigned = { schema: XYZAC_COMMAND_LEDGER_SCHEMA, operation_schema: LEDGER_SCHEMA, phases: [...LEDGER_PHASES], scope: this.scope, entries: exported.entries, pending_count: pending.length };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
}
|
||||
45
web/worker/xyzac-scheduler.mjs
Normal file
45
web/worker/xyzac-scheduler.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import { SimScheduler, SCHEDULER_PHASES } from "./sim-scheduler.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
export const XYZAC_SCHEDULER_SCHEMA = "xyzac-scheduler.v1";
|
||||
export const XYZAC_SCHEDULER_PHASES = Object.freeze([...SCHEDULER_PHASES]);
|
||||
|
||||
export class XyzacScheduler extends SimScheduler {
|
||||
constructor(core, { budget = 1_000_000, cycleTime = 0.01 } = {}) {
|
||||
super(core, { budget });
|
||||
this.cycle_time = cycleTime;
|
||||
this.authority = "linuxcnc-wasm-worker";
|
||||
this.clock = "logical_tick";
|
||||
this.machine = Object.freeze({ coordinates: "XYZAC", joint_count: 5, rotary_units: "degrees", kinematics: "xyzac-trt" });
|
||||
}
|
||||
|
||||
step(cycles, callbacks = {}) {
|
||||
if (!Number.isSafeInteger(cycles) || cycles < 0) throw new Error("xyzac_scheduler_cycles_invalid");
|
||||
const result = super.step(cycles, callbacks);
|
||||
return this.export({ last_step_cycles: cycles, state_hash: result.state_hash });
|
||||
}
|
||||
|
||||
export(extra = {}) {
|
||||
const unsigned = {
|
||||
schema: XYZAC_SCHEDULER_SCHEMA,
|
||||
authority: this.authority,
|
||||
clock: this.clock,
|
||||
cycle_time: this.cycle_time,
|
||||
machine: this.machine,
|
||||
budget: this.budget,
|
||||
tick: this.tick,
|
||||
last_cycles: this.last_cycles,
|
||||
phase_trace: [...this.phase_trace],
|
||||
...extra,
|
||||
};
|
||||
delete unsigned.state_hash;
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restore(value) {
|
||||
if (!value || value.schema !== XYZAC_SCHEDULER_SCHEMA || value.clock !== "logical_tick" || value.machine?.coordinates !== "XYZAC" || value.machine?.joint_count !== 5) throw new Error("xyzac_scheduler_restore_scope");
|
||||
const base = { schema: "sim_scheduler.v1", budget: value.budget, last_cycles: value.last_cycles, phase_trace: value.phase_trace, tick: value.tick, state_hash: fnv1a64({ schema: "sim_scheduler.v1", budget: value.budget, phase_trace: value.phase_trace, tick: value.tick }) };
|
||||
super.restore(base);
|
||||
return this.export();
|
||||
}
|
||||
}
|
||||
40
web/worker/xyzac-session.mjs
Normal file
40
web/worker/xyzac-session.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { SimSession, CONTEXT_ID, PROTOCOL_SCHEMA } from "./sim-session.mjs";
|
||||
import { XyzacCommandLedger, XYZAC_COMMAND_LEDGER_SCHEMA } from "./xyzac-command-ledger.mjs";
|
||||
import { XyzacScheduler, XYZAC_SCHEDULER_SCHEMA } from "./xyzac-scheduler.mjs";
|
||||
|
||||
export const XYZAC_SESSION_SCHEMA = "xyzac-session.v1";
|
||||
|
||||
export class XyzacSession extends SimSession {
|
||||
constructor(core, options = {}) {
|
||||
super(core, options);
|
||||
this.ledger = new XyzacCommandLedger();
|
||||
this.scheduler = new XyzacScheduler(core);
|
||||
this.session_scope = "selected_xyzac_simulation";
|
||||
}
|
||||
|
||||
authority() {
|
||||
return {
|
||||
schema: XYZAC_SESSION_SCHEMA,
|
||||
authority: "linuxcnc-wasm-worker",
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
scope: this.session_scope,
|
||||
protocol_schema: PROTOCOL_SCHEMA,
|
||||
ledger_schema: XYZAC_COMMAND_LEDGER_SCHEMA,
|
||||
scheduler_schema: XYZAC_SCHEDULER_SCHEMA,
|
||||
machine: { coordinates: "XYZAC", joint_count: 5, kinematics: "xyzac-trt", rotary_units: "degrees" },
|
||||
};
|
||||
}
|
||||
|
||||
accept(input) {
|
||||
const response = super.accept(input);
|
||||
if (response?.payload && typeof response.payload === "object") {
|
||||
response.payload.xyzac_session = this.authority();
|
||||
response.payload.xyzac_ledger = this.ledger.audit();
|
||||
response.payload.xyzac_scheduler = this.scheduler.export();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export { CONTEXT_ID, PROTOCOL_SCHEMA };
|
||||
@@ -38,7 +38,7 @@ export async function runOpfsProbe({ storage, projectId = "s6-05-opfs", mode = "
|
||||
const probe = opfsCapability({ navigator: { storage } });
|
||||
if (probe.status !== "verified") return { schema: OPFS_RUNTIME_SCHEMA, ...probe, project_id: projectId };
|
||||
if (!validProjectId(projectId)) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_project_id_invalid" };
|
||||
if (!["round_trip", "write", "read"].includes(mode)) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_mode_invalid", project_id: projectId };
|
||||
if (!["round_trip", "write", "read", "corrupt"].includes(mode)) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_mode_invalid", project_id: projectId };
|
||||
let access = null;
|
||||
try {
|
||||
const rootResult = await rootHandle(storage);
|
||||
@@ -64,6 +64,18 @@ export async function runOpfsProbe({ storage, projectId = "s6-05-opfs", mode = "
|
||||
access.close();
|
||||
access = null;
|
||||
if (mode === "write") return { schema: OPFS_RUNTIME_SCHEMA, status: "verified", mode, project_id: projectId, bytes: encoded.byteLength, commit_seq: record.commit_seq, content_hash: record.content_hash, cleanup: false };
|
||||
if (mode === "corrupt") {
|
||||
const corrupt = await file.createSyncAccessHandle();
|
||||
corrupt.write(TEXT_ENCODER.encode("{corrupt"), { at: 0 });
|
||||
corrupt.flush();
|
||||
corrupt.close();
|
||||
let damaged = null;
|
||||
try { damaged = JSON.parse(await (await file.getFile()).text()); } catch { damaged = null; }
|
||||
if (cleanup) { await project.removeEntry(FILE_NAME); await directory.removeEntry(projectId); }
|
||||
return !validateCommittedRecord(damaged, projectId)
|
||||
? { schema: OPFS_RUNTIME_SCHEMA, status: "verified", mode, project_id: projectId, corruption_detected: true, cleanup }
|
||||
: { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_corruption_undetected", project_id: projectId };
|
||||
}
|
||||
const persisted = JSON.parse(await (await file.getFile()).text());
|
||||
const valid = written === encoded.byteLength && size === encoded.byteLength && JSON.stringify(persisted) === JSON.stringify(record);
|
||||
if (cleanup) { await project.removeEntry(FILE_NAME); await directory.removeEntry(projectId); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,17 +5,18 @@ import { isOptionalSourceKey } from "./linuxcnc-source-semantics.mjs";
|
||||
|
||||
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
|
||||
const RESOURCE_KEYS = new Set([
|
||||
"HAL", "HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE", "OPEN_FILE",
|
||||
"HAL", "HALFILE", "POSTGUI_HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE", "OPEN_FILE",
|
||||
"PROGRAM", "PROGRAMS", "PYTHON", "PYTHONFILE", "VCP", "VCPFILE", "CLPFILE",
|
||||
"CLASSICLADDER", "INTRO_GRAPHIC",
|
||||
"PYVCP", "NGCGUI_SUBFILE", "INTRO_GRAPHIC", "SUBROUTINE_PATH", "REMAP",
|
||||
]);
|
||||
const MANIFEST = new Set(SIM_FILE_INDEX);
|
||||
const PRELOADED_HAL_COMPONENTS = Object.freeze(["corexykins", "lineardeltakins", "motmod", "pid", "rotarydeltakins", "stepgen", "trivkins", "tpmod", "homemod"]);
|
||||
const PRELOADED_HAL_COMPONENTS = Object.freeze(["corexykins", "lineardeltakins", "motmod", "pid", "rotarydeltakins", "stepgen", "trivkins", "xyzac-trt-kins", "xyzbc-trt-kins", "tpmod", "homemod"]);
|
||||
const CASE_KINEMATICS = Object.freeze({
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini": Object.freeze({ type: "corexy", path_mode: "kins" }),
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini": Object.freeze({ type: "corexy", path_mode: "hal" }),
|
||||
"linuxcnc/configs/sim/axis/ldelta.ini": Object.freeze({ type: "lineardelta", path_mode: "kins" }),
|
||||
"linuxcnc/configs/sim/axis/rdelta.ini": Object.freeze({ type: "rotarydelta", path_mode: "kins" }),
|
||||
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini": Object.freeze({ type: "xyzac-trt", path_mode: "wasm", coordinates: "XYZAC", joint_count: 5, max_linear_velocity: 35, switchkins: { 0: "identity", 1: "xyzac", 2: "userk" }, offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 } }),
|
||||
});
|
||||
const RUNTIME_FIXTURES = Object.freeze({
|
||||
"linuxcnc/configs/sim/axis/minimal_xyz.ini": Object.freeze({
|
||||
@@ -42,6 +43,12 @@ const RUNTIME_FIXTURES = Object.freeze({
|
||||
with: "HALFILE = xz_both_auto_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated XZ BOTH AUTO HAL command fixture",
|
||||
}),
|
||||
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini": Object.freeze({
|
||||
generated_hal: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt-headless_cmds.hal",
|
||||
replace: "HALFILE = LIB:basic_sim.tcl",
|
||||
with: "HALFILE = xyzac-trt-headless_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated XYZAC HAL command fixture",
|
||||
}),
|
||||
});
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
@@ -82,6 +89,10 @@ function candidates(source, raw, key = null, section = null) {
|
||||
const libraryPath = normalize(`linuxcnc/lib/hallib/${name}`);
|
||||
return libraryPath ? [libraryPath] : [];
|
||||
}
|
||||
if (["NGCGUI_SUBFILE", "REMAP"].includes(String(key).toUpperCase())) {
|
||||
const subroutine = normalize(`${dirname(source)}/remap_subs/${first}`);
|
||||
if (subroutine) return [subroutine];
|
||||
}
|
||||
if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) {
|
||||
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`].filter(Boolean);
|
||||
const indexed = halCandidates.filter(inManifest);
|
||||
@@ -128,6 +139,16 @@ function resourceReferences(source, content) {
|
||||
const line = rawLine.split("#", 1)[0].trim();
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) { section = sectionMatch[1].trim(); continue; }
|
||||
if (section === "RS274NGC" && /^SUBROUTINE_PATH\s*=\s*/i.test(line)) {
|
||||
const directory = normalize(`${dirname(source)}/${line.split("=", 2)[1].trim()}`);
|
||||
for (const candidate of SIM_FILE_INDEX.filter((path) => directory && path.startsWith(`${directory}/`) && path.endsWith(".ngc"))) references.push({ path: candidate, key: "SUBROUTINE_PATH", section, source });
|
||||
continue;
|
||||
}
|
||||
if (section === "RS274NGC" && /^REMAP\s*=\s*/i.test(line)) {
|
||||
const name = /ngc=([^\s]+)/i.exec(line)?.[1];
|
||||
if (name) { const candidate = normalize(`${dirname(source)}/remap_subs/${name}.ngc`); if (candidate) references.push({ path: candidate, key: "REMAP", section, source }); }
|
||||
continue;
|
||||
}
|
||||
const assignment = line.match(/^([^=:#]+?)\s*(?:=|:)\s*(.*)$/);
|
||||
if (assignment && RESOURCE_KEYS.has(assignment[1].trim().toUpperCase())) {
|
||||
const key = assignment[1].trim().toUpperCase();
|
||||
@@ -236,6 +257,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] } } : {}),
|
||||
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
165
web/workspace/xyzac-trt-case-contract.mjs
Normal file
165
web/workspace/xyzac-trt-case-contract.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
export const XYZAC_TRT_CASE_CONTRACT = Object.freeze({
|
||||
"authority": "linuxcnc-wasm-worker",
|
||||
"case": {
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||
"sha256": "f985970db293b70c055e4c41fc316d0c16037b5dc7ce07490e18f554e851f811",
|
||||
"status": "indexed"
|
||||
},
|
||||
"commands": [
|
||||
"MDI_EXECUTE",
|
||||
"PROGRAM_LOAD",
|
||||
"PROGRAM_RUN",
|
||||
"PROGRAM_PAUSE",
|
||||
"PROGRAM_RESUME",
|
||||
"PROGRAM_ABORT",
|
||||
"JOG_START",
|
||||
"JOG_STOP",
|
||||
"HOME",
|
||||
"KINEMATICS_SELECT",
|
||||
"SNAPSHOT_REQUEST"
|
||||
],
|
||||
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
|
||||
"js_authority": 0,
|
||||
"report_sha256": "817531c109c341ccda78b085d2613f35379a716a98f54c9561a118f995b6cd5e",
|
||||
"resources": [
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||
"role": "case-entrypoint",
|
||||
"sha256": "f985970db293b70c055e4c41fc316d0c16037b5dc7ce07490e18f554e851f811",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.xml",
|
||||
"role": "PYVCP",
|
||||
"sha256": "84efa01584693ab8f4a9c0730e341a9dbe87d9341472081ee80d59a0bfb90941",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/switchkins_postgui.hal",
|
||||
"role": "POSTGUI_HALFILE",
|
||||
"sha256": "55e969c22ca9054a818dbedc8ec50bed768d9ab3d8c4bfdfcfc78e912e9271c4",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.tbl",
|
||||
"role": "TOOL_TABLE",
|
||||
"sha256": "41cf3c92574be99ecd6ed45e1869158b184620419d5d95a41649d25f15e141ff",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac.var",
|
||||
"role": "PARAMETER_FILE",
|
||||
"sha256": "163bbf80485fe2fe12700c1acb4b05eff774df68176d17e4eef5de8a7024c1ae",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc",
|
||||
"role": "OPEN_FILE",
|
||||
"sha256": "5afc03d5c1b692edd4154a6d37e5f94d74936a7bd5e82dc9c022f7bc21c7dc17",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
|
||||
"role": "REMAP:M428",
|
||||
"sha256": "a0aee1d6b481a979849a17c0a23aa7524f52cbd875c92cbde3b73bf36f91d17b",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
|
||||
"role": "REMAP:M429",
|
||||
"sha256": "160479860b84e55ace49ffceb9594285ee90c521ba5eef916ef47ac8a6b3349d",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
|
||||
"role": "REMAP:M430",
|
||||
"sha256": "41090461a936a63432252d42f277c925f9aa5a0faf0497933910c3b051fcfe4f",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzac_switchkins_sub.ngc",
|
||||
"role": "NGCGUI_SUBFILE",
|
||||
"sha256": "833bf21b8ed0ce600dcf5819234320d85348d906bbba390b62f3704836ea254d",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/centering.ngc",
|
||||
"role": "NGCGUI_SUBFILE",
|
||||
"sha256": "58cf585aa10480a80fe8ac8f2eab5e877adca51b4b3518b337eab476de01b11c",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_ac.ngc",
|
||||
"role": "RTCP-SUBROUTINE",
|
||||
"sha256": "06cb755e33dc35d9a179b3c48e4203c8469632ab248d94379b10f2538dd0b54a",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/lib/hallib/basic_sim.tcl",
|
||||
"role": "HALFILE:LIB:basic_sim.tcl",
|
||||
"sha256": "d9851b0a8861e6d66de745a8ae230d4b38b65c34f8b05bdb6705792d2d4050be",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/linuxcnc.gif",
|
||||
"role": "INTRO_GRAPHIC",
|
||||
"sha256": "6c6da4afc3ecdc1b812dd6878318da0120996bd6889b4dd2ddb480b905d9f4b1",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "4d4769cbbb47ad7bf82c12bb1545c3854addebebe4642a3f50c8ccf5c560bcda",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "f46a1f485ca85e4e9900b7a9b4f59fed7fc9d7cf27a403e15841b933ab98f0bf",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "e90f0b4b6c43809da94a8170ee1029b5afc3e1bbe9bf2ae66298a8baefad013c",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "3cdffc0481c1d6b0a0a67151a06448c686d4109249ed73438d5ff4e72b9e7267",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_2.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "a0b05e672e7f8b5d9c8f42ac3fd16320f68a67926ae1413d361d60cc508d953f",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_3.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "0e7e3c2fe554f888d3f5ef055390a7153ffa7f9eeedaac116f78832ff7a31cc1",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
|
||||
"role": "DEMO_PROGRAM",
|
||||
"sha256": "75db56e24242b68e32a9a7f1943fe240c5f518cc1866ee823ea158f9f87ce9fe",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_bc.ngc",
|
||||
"role": "REMAP_SUBROUTINE",
|
||||
"sha256": "2b8b36bf78d9227bd061083f883c72c7b0b205a8fe6d0a9407203b5375fe89fa",
|
||||
"status": "resolved"
|
||||
},
|
||||
{
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzbc_switchkins_sub.ngc",
|
||||
"role": "REMAP_SUBROUTINE",
|
||||
"sha256": "7265e848d0e123f439208caa88afccdf2803f34770042c778a8797a25d44537a",
|
||||
"status": "resolved"
|
||||
}
|
||||
],
|
||||
"schema": "xyzac-trt-case-contract.v1"
|
||||
});
|
||||
127
web/workspace/xyzac-trt-case-loader.mjs
Normal file
127
web/workspace/xyzac-trt-case-loader.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { WorkspaceRuntime } from "./runtime.mjs";
|
||||
import { parseIni } from "./ini-parser.mjs";
|
||||
import { ConfigurationStartupStateMachine } from "./configuration-startup.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "./sim-case-loader.mjs";
|
||||
|
||||
export const XYZAC_TRT_CASE_LOADER_SCHEMA = "xyzac-trt-case-loader.v1";
|
||||
export const XYZAC_TRT_CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
export const XYZAC_TRT_STARTUP_STATES = Object.freeze(["TASK_NEW", "CONFIG_RESOLVED", "HAL_READY", "RUNTIME_READY", "TASK_READY"]);
|
||||
|
||||
const CASE_DIR = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting";
|
||||
const REQUIRED_RESOURCES = Object.freeze([
|
||||
[XYZAC_TRT_CASE, "case_entrypoint"],
|
||||
[`${CASE_DIR}/xyzac-trt.xml`, "pyvcp"],
|
||||
[`${CASE_DIR}/switchkins_postgui.hal`, "postgui_hal"],
|
||||
[`${CASE_DIR}/xyzac-trt.tbl`, "tool_table"],
|
||||
[`${CASE_DIR}/xyzac.var`, "parameter_file"],
|
||||
[`${CASE_DIR}/demos/xyzac_switchkins.ngc`, "open_file"],
|
||||
[`${CASE_DIR}/remap_subs/428remap.ngc`, "remap_m428"],
|
||||
[`${CASE_DIR}/remap_subs/429remap.ngc`, "remap_m429"],
|
||||
[`${CASE_DIR}/remap_subs/430remap.ngc`, "remap_m430"],
|
||||
[`${CASE_DIR}/remap_subs/xyzac_switchkins_sub.ngc`, "ngcgui_subfile"],
|
||||
[`${CASE_DIR}/remap_subs/centering.ngc`, "ngcgui_subfile"],
|
||||
[`${CASE_DIR}/remap_subs/helix_ac.ngc`, "rtcp_subroutine"],
|
||||
["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "vismach_source"],
|
||||
["linuxcnc/lib/hallib/basic_sim.tcl", "hal_library"],
|
||||
["linuxcnc/linuxcnc.gif", "intro_graphic"],
|
||||
]);
|
||||
const REQUIRED_SECTIONS = Object.freeze(["DISPLAY", "TASK", "HAL", "RS274NGC", "KINS", "TRAJ", "EMCIO"]);
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
|
||||
async function readExact(fetchImpl, baseUrl, path) {
|
||||
try {
|
||||
const response = await fetchImpl(`${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"));
|
||||
if (!response?.ok) return null;
|
||||
return await response.text();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function resourceRows(files) {
|
||||
return REQUIRED_RESOURCES.map(([path, role]) => {
|
||||
const content = files[path];
|
||||
return {
|
||||
path,
|
||||
role,
|
||||
status: typeof content === "string" ? "resolved" : "BLOCKED_RESOURCE",
|
||||
bytes: typeof content === "string" ? new TextEncoder().encode(content).byteLength : null,
|
||||
data_hash: typeof content === "string" ? fnv1a64(content) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function xyzacSections(parsed) {
|
||||
const sections = Object.keys(parsed.sections ?? {}).map((name) => name.toUpperCase());
|
||||
return Object.fromEntries(REQUIRED_SECTIONS.map((name) => [name, sections.includes(name)]));
|
||||
}
|
||||
|
||||
function startupProjection(startup, exactResources, sections) {
|
||||
const generic = startup;
|
||||
const resolved = exactResources.every((row) => row.status === "resolved");
|
||||
const requiredSections = Object.values(sections).every(Boolean);
|
||||
const state = generic.state === "TASK_READY" && resolved && requiredSections ? "TASK_READY" : generic.state === "BLOCKED" ? "BLOCKED" : "RUNTIME_READY";
|
||||
const transitions = [{ from: null, to: "TASK_NEW" }];
|
||||
if (generic.state !== "BLOCKED") {
|
||||
transitions.push({ from: "TASK_NEW", to: "CONFIG_RESOLVED" });
|
||||
transitions.push({ from: "CONFIG_RESOLVED", to: "HAL_READY" });
|
||||
transitions.push({ from: "HAL_READY", to: "RUNTIME_READY" });
|
||||
if (state === "TASK_READY") transitions.push({ from: "RUNTIME_READY", to: "TASK_READY" });
|
||||
} else transitions.push({ from: "TASK_NEW", to: "BLOCKED" });
|
||||
return {
|
||||
schema: "xyzac-trt-startup.v1",
|
||||
state,
|
||||
status: state === "TASK_READY" ? "ready" : "blocked",
|
||||
transitions,
|
||||
generic_state: generic.state,
|
||||
generic_status: generic.status,
|
||||
diagnostics: generic.diagnostics,
|
||||
hal: generic.hal,
|
||||
exact_resources: exactResources,
|
||||
required_sections: sections,
|
||||
state_hash: fnv1a64({ state, transitions, diagnostics: generic.diagnostics, exact_resources: exactResources, required_sections: sections }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadXyzacTrtCase({ fetchImpl = globalThis.fetch, baseUrl = "", core = null, task = null } = {}) {
|
||||
if (typeof fetchImpl !== "function") throw new Error("xyzac_case_fetch_required");
|
||||
const bundle = await loadSimulationCase({ path: XYZAC_TRT_CASE, fetchImpl, baseUrl });
|
||||
const files = { ...bundle.files };
|
||||
for (const [path] of REQUIRED_RESOURCES) if (!(path in files)) {
|
||||
const content = await readExact(fetchImpl, baseUrl, path);
|
||||
if (content !== null) files[path] = content;
|
||||
}
|
||||
const exact = resourceRows(files);
|
||||
const caseText = files[XYZAC_TRT_CASE];
|
||||
const workspace = new WorkspaceRuntime({ files });
|
||||
let parsed = null;
|
||||
let startup = null;
|
||||
let parsedError = null;
|
||||
try {
|
||||
parsed = parseIni({ workspace, entrypoint: XYZAC_TRT_CASE });
|
||||
const machine = new ConfigurationStartupStateMachine({ workspace, entrypoint: XYZAC_TRT_CASE, core, task, halManifest: bundle.halManifest ?? [] });
|
||||
startup = startupProjection(machine.run(), exact, xyzacSections(parsed));
|
||||
} catch (error) {
|
||||
parsedError = { code: error?.code ?? "xyzac_startup_exception", message: String(error?.message ?? error) };
|
||||
}
|
||||
const sections = parsed ? xyzacSections(parsed) : Object.fromEntries(REQUIRED_SECTIONS.map((name) => [name, false]));
|
||||
const missing = exact.filter((row) => row.status !== "resolved").map((row) => row.path);
|
||||
const unsigned = {
|
||||
schema: XYZAC_TRT_CASE_LOADER_SCHEMA,
|
||||
case: XYZAC_TRT_CASE,
|
||||
case_hash: typeof caseText === "string" ? fnv1a64(caseText) : null,
|
||||
bundle_status: bundle.status,
|
||||
bundle_hash: bundle.state_hash,
|
||||
fetched: [...new Set(bundle.fetched ?? [])].sort(),
|
||||
missing: [...new Set([...(bundle.missing ?? []), ...missing])].sort(),
|
||||
exact_resources: exact,
|
||||
required_sections: sections,
|
||||
ini: parsed ? { state_hash: parsed.state_hash, graph: parsed.graph, includes: parsed.includes } : null,
|
||||
startup,
|
||||
parsed_error: parsedError,
|
||||
machine: { coordinates: "XYZAC", joint_count: 5, rotary_units: "degrees", kinematics: "xyzac-trt", switchkins: { 0: "identity", 1: "xyzac", 2: "userk" } },
|
||||
};
|
||||
return { ...clone(unsigned), status: startup?.status === "ready" && missing.length === 0 ? "ready" : "blocked", state_hash: fnv1a64(unsigned), worker_configuration: toWorkerConfiguration({ ...bundle, files, missing: unsigned.missing, status: unsigned.missing.length ? "blocked" : "ready" }) };
|
||||
}
|
||||
|
||||
export { REQUIRED_RESOURCES, REQUIRED_SECTIONS };
|
||||
1996
web/workspace/xyzac-trt-source-slices.mjs
Normal file
1996
web/workspace/xyzac-trt-source-slices.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user