Complete XYZAC LinuxCNC WASM parity and smooth 3D preview
This commit is contained in:
@@ -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"]);
|
||||
Reference in New Issue
Block a user