Complete 3axis tutorial native and Web/WASM parity tools

This commit is contained in:
2026-08-24 14:01:30 +08:00
parent 938654b19d
commit 7e90142b63
147 changed files with 17639 additions and 557 deletions

View File

@@ -11,6 +11,7 @@ export const AXIS_COMMANDS = Object.freeze({
jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER",
spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE",
toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG",
reset: "RESET",
});
export const AXIS_BOUNDARIES = Object.freeze({
@@ -35,8 +36,17 @@ export function normalizeAxisState(input = {}) {
const programStatus = string(program.program_status ?? input.program_status, "IDLE").toLowerCase();
const queue = number(task.queued_mdi_commands ?? input.queued_mdi_commands);
const queueLimit = Math.max(1, number(task.max_queued_mdi_commands ?? input.max_queued_mdi_commands, 10));
const homed = Array.isArray(motion.homed) ? motion.homed : (Array.isArray(input.homed) ? input.homed : []);
const homed = Array.isArray(motion.homed_axes)
? motion.homed_axes
: Array.isArray(motion.homed)
? motion.homed
: Array.isArray(input.homed)
? input.homed
: motion.homed === true
? [true, true, true]
: [];
const limits = input.on_any_limit ?? motion.on_any_limit ?? motion.limit ?? false;
const fault = rawTaskState === "FAULT" || Boolean(motion.fault || devices.fault || input.fault);
const tool = devices.tool ?? input.tool ?? {};
const toolNumber = number(tool.current ?? input.tool_in_spindle);
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded ?? (program.program_status && String(program.program_status).toLowerCase() !== "idle"));
@@ -44,7 +54,7 @@ export function normalizeAxisState(input = {}) {
return Object.freeze({
taskState, interpState, mode, motionMode, programStatus, queue, queueLimit,
taskFile, hasEditor, onAnyLimit: Boolean(limits), toolLoaded: toolNumber > 0,
allHomed: homed.length > 0 && homed.every(Boolean), homed,
allHomed: homed.length > 0 && homed.every(Boolean), homed, fault,
});
}
@@ -58,10 +68,13 @@ export function axisGate(action, input = {}) {
const trajectoryTouchOffAllowed = state.motionMode !== "FREE" || input.kinematicsIdentity === true;
let enabled = false;
let reason = "axis_gate_unknown_action";
if (state.fault && !["estop", "recover", "overrideLimits"].includes(action)) return Object.freeze({ action, enabled: false, reason: "axis_fault_active", taskState: state.taskState, interpState: state.interpState });
switch (action) {
case "estop": enabled = true; reason = estop ? "axis_estop_reset_available" : "axis_estop_available"; break;
case "recover": enabled = true; reason = "axis_recover_available"; break;
case "power": enabled = state.taskState !== "ESTOP"; reason = enabled ? "axis_power_available" : "axis_estop_active"; break;
case "override": enabled = machineOn && !estop; reason = enabled ? "axis_override_available" : estop ? "axis_estop_active" : "axis_machine_off"; break;
case "reset": enabled = machineOn && !running && !state.onAnyLimit; reason = enabled ? "axis_reset_available" : state.onAnyLimit ? "axis_limit_active" : "axis_reset_state_gate"; break;
case "open": enabled = idle; reason = enabled ? "axis_interp_idle" : "axis_interp_running"; break;
case "load": enabled = machineOn && idle; reason = enabled ? "axis_program_load_available" : !machineOn ? "axis_machine_off" : "axis_interp_running"; break;
case "reload": enabled = idle && state.taskFile; reason = !idle ? "axis_interp_running" : state.taskFile ? "axis_reload_available" : "axis_taskfile_missing"; break;
@@ -74,9 +87,9 @@ export function axisGate(action, input = {}) {
case "stop": case "abort": enabled = machineOn && running; reason = enabled ? "axis_stop_available" : "axis_stop_state_gate"; break;
case "restart": enabled = machineOn && idle && state.taskFile; reason = enabled ? "axis_restart_available" : !machineOn ? "axis_machine_off" : !state.taskFile ? "axis_taskfile_missing" : "axis_restart_state_gate"; break;
case "mdi": enabled = machineOn && state.queue < state.queueLimit && (idle || state.mode === "MDI"); reason = enabled ? "axis_mdi_available" : estop ? "axis_estop_active" : !machineOn ? "axis_machine_off" : state.queue >= state.queueLimit ? "axis_mdi_queue_full" : "axis_interp_running"; break;
case "jogStart": case "jogStop": case "home": case "spindle": case "coolant": enabled = manualOk; reason = enabled ? "axis_manual_available" : estop ? "axis_estop_active" : !machineOn ? "axis_machine_off" : "axis_manual_state_gate"; break;
case "touchOff": enabled = manualOk && trajectoryTouchOffAllowed; reason = !manualOk ? "axis_manual_state_gate" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : "axis_touch_off_available"; break;
case "toolTouchOff": enabled = manualOk && trajectoryTouchOffAllowed && state.toolLoaded; reason = !state.toolLoaded ? "axis_tool_missing" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : !manualOk ? "axis_manual_state_gate" : "axis_tool_touch_off_available"; break;
case "jogStart": case "jogStop": case "home": case "spindle": case "coolant": enabled = manualOk && state.mode === "MANUAL"; reason = enabled ? "axis_manual_available" : estop ? "axis_estop_active" : !machineOn ? "axis_machine_off" : state.mode !== "MANUAL" ? "axis_manual_mode_required" : "axis_manual_state_gate"; break;
case "touchOff": enabled = manualOk && state.mode === "MANUAL" && trajectoryTouchOffAllowed; reason = !manualOk ? "axis_manual_state_gate" : state.mode !== "MANUAL" ? "axis_manual_mode_required" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : "axis_touch_off_available"; break;
case "toolTouchOff": enabled = manualOk && state.mode === "MANUAL" && trajectoryTouchOffAllowed && state.toolLoaded; reason = !state.toolLoaded ? "axis_tool_missing" : state.mode !== "MANUAL" ? "axis_manual_mode_required" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : !manualOk ? "axis_manual_state_gate" : "axis_tool_touch_off_available"; break;
case "overrideLimits": enabled = state.onAnyLimit; reason = enabled ? "axis_limit_override_available" : "axis_no_active_limit"; break;
default: break;
}

View File

@@ -27,7 +27,8 @@ export class AxisOperationController {
async execute(kind, payload = {}, { state = {}, action = COMMAND_ACTIONS[kind] ?? null, gate = true } = {}) {
const operationId = this.nextOperationId(kind);
const gateResult = action ? axisGate(action, state) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
if (gate && !gateResult.enabled) {
const authoritativeGate = action && state?.gates?.[action];
if (gate && !gateResult.enabled && !authoritativeGate) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: gateResult.reason }));
throw new AxisGateError(gateResult);
}

View File

@@ -6,20 +6,22 @@ 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";
export const SELECTED_AXIS3_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const DEFAULT_CASE = SELECTED_AXIS3_CASE;
const DEFAULT_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
const PROGRAM_PATH = "linuxcnc/configs/nc_files/3dtest.ngc";
const DEFAULT_PROGRAM = "";
type Action = (kind: string, payload?: Record<string, unknown>) => void;
type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void;
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean; feature?: string };
function IconButton({ label, asset, onClick, disabled = false, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) {
return <button type="button" data-axis-feature={feature} className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
}
function TextButton({ children, onClick, disabled = false, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; danger?: boolean; feature?: string }) {
return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} disabled={disabled}>{children}</button>;
function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler<HTMLButtonElement>; onPointerUp?: React.PointerEventHandler<HTMLButtonElement>; onPointerCancel?: React.PointerEventHandler<HTMLButtonElement>; onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>; ariaLabel?: string; disabled?: boolean; danger?: boolean; feature?: string }) {
return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeave} aria-label={ariaLabel} disabled={disabled}>{children}</button>;
}
function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; }
@@ -36,25 +38,27 @@ 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 ManualControl({ action, ready, selectedAxis, setSelectedAxis, spindleOn, coolant, onSpindle, onCoolant }: { action: Action; ready: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void }) {
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);
const [maxVelocity, setMaxVelocity] = useState(100);
const [overrideLimits, setOverrideLimits] = useState(false);
const [brake, setBrake] = useState(false);
const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, speed: increment === "Continuous" ? 1 : Number(increment) });
const [jogging, setJogging] = useState(false);
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-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!ready} onClick={() => jog(-1)}>-</TextButton><TextButton disabled={!ready} onClick={() => jog(1)}>+</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={() => action("JOG_STOP")}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!ready} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</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-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>
</div><div className="manual-sliders"><label>Jog Speed:<output>{jogSpeed}%</output><input type="range" min="6" max="100" value={jogSpeed} onChange={(event) => setJogSpeed(Number(event.target.value))} /></label><label>Max Velocity:<output>{maxVelocity}%</output><input type="range" min="6" max="100" value={maxVelocity} onChange={(event) => setMaxVelocity(Number(event.target.value))} /></label></div></section>;
</div><div className="manual-device-block"><h3>Tool / Probe:</h3><div className="manual-action-row"><TextButton disabled={!ready} onClick={() => action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1</TextButton><TextButton disabled={!ready} onClick={() => action("TOOL_CHANGE", {})}>Change</TextButton><TextButton disabled={!ready} onClick={() => action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe</TextButton><TextButton disabled={!ready} onClick={() => action("PROBE_CONFIG", { action: "disarm" })}>Disarm</TextButton></div><span className="device-status">Tool {devices.tool?.current ?? 0} / {devices.tool?.state ?? "idle"}; Probe {devices.probe?.state ?? "idle"}</span></div><div className="manual-sliders"><label>Jog Speed:<output>{jogSpeed}%</output><input type="range" min="6" max="100" value={jogSpeed} onChange={(event) => setJogSpeed(Number(event.target.value))} /></label><label>Max Velocity:<output>{maxVelocity}%</output><input type="range" min="6" max="100" value={maxVelocity} onChange={(event) => setMaxVelocity(Number(event.target.value))} /></label></div></section>;
}
function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
const [command, setCommand] = useState("G0 X0");
const [history, setHistory] = useState<string[]>([]);
const submit = () => { if (!command.trim()) return; action("MDI_EXECUTE", { block: command }); setHistory((items) => [command, ...items].slice(0, 20)); };
const submit = () => { if (!command.trim() || !ready) return; void action("MDI_EXECUTE", { block: command }); setHistory((items) => [command, ...items].slice(0, 20)); };
return <section className="mdi-control" data-axis-feature="AXIS-CTRL-03" role="tabpanel" aria-label="MDI Control"><label className="mdi-label">MDI Command<input value={command} onChange={(event) => setCommand(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} /></label><TextButton feature="AXIS-CTRL-03" disabled={!ready} onClick={submit}>Go</TextButton><h3>MDI History</h3><ol>{history.length ? history.map((line, index) => <li key={`${line}-${index}`}><button type="button" data-axis-feature="AXIS-CTRL-03" onClick={() => setCommand(line)}>{line}</button></li>) : <li className="empty-history">No commands</li>}</ol></section>;
}
@@ -62,12 +66,28 @@ function DroView({ axes, state, coordType, displayType, metric, setCoordType, se
const labels = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"];
const scale = metric ? 1 : 1 / 25.4;
const format = (value: number) => (value * scale).toFixed(4);
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-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(value)}</output><output>{format(value)}</output><output>{index < 3 ? format(10 - value) : "0.0000"}</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 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>;
}
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready, currentLine, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; ready: boolean; currentLine: number | null; diagnostic: string | null }) {
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 }) {
const lines = text.split("\n");
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => <span key={index} className={index + 1 === currentLine ? "current-line" : ""} aria-current={index + 1 === currentLine ? "true" : undefined}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!ready}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!ready}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!ready}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => <span key={index} className={index + 1 === currentLine ? "current-line" : ""} aria-current={index + 1 === currentLine ? "true" : undefined}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
}
function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) {
const configuration = state.snapshot?.configuration as Record<string, any> | null | undefined;
const diagnostics = Array.isArray(configuration?.diagnostics) ? configuration.diagnostics : [];
const gateRows = Object.entries(gates).filter(([, value]) => !value.enabled);
return <section className="axis-diagnostics" data-axis-feature="AXIS-UI-07" aria-label="Diagnostics">
<div className="diagnostic-summary"><strong>{configuration?.state ?? "NOT_LOADED"}</strong><span>Tick {state.tick}</span><span>Hash {state.snapshot?.state_hash ?? "-"}</span></div>
{state.error && <div className="diagnostic-error" role="alert"><b>{state.error.code}</b><span>{state.error.message ?? "Worker rejected the operation"}</span></div>}
<div className="diagnostic-section"><h3>Startup resources</h3>{configuration ? <><span>Entrypoint: {configuration.entrypoint ?? "-"}</span><span>Resources: {configuration.resources?.resolved_count ?? 0} resolved / {configuration.resources?.blocked_count ?? 0} blocked</span><span>HAL: {configuration.hal?.record_count ?? 0} records / {configuration.hal?.blocked_count ?? 0} blocked</span></> : <span>No configuration snapshot</span>}</div>
<div className="diagnostic-section"><h3>Disabled operations</h3>{gateRows.length ? gateRows.map(([name, value]) => <div className="diagnostic-row" key={name}><code>{name}</code><span>{value.reason}</span></div>) : <span>All panel operations are enabled</span>}</div>
<div className="diagnostic-section"><h3>Source diagnostics</h3>{diagnostics.length ? diagnostics.map((item: any, index: number) => <div className="diagnostic-row" key={`${item.code ?? "diagnostic"}-${index}`}><code>{item.code ?? "diagnostic"}</code><span>{item.path ?? item.source ?? "-"}{item.line ? `:${item.line}` : ""}</span></div>) : <span>No startup diagnostics</span>}</div>
</section>;
}
export function App() {
@@ -79,10 +99,8 @@ export function App() {
const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramText] = useState(DEFAULT_PROGRAM);
const [selectedAxis, setSelectedAxis] = useState("x");
const [leftTab, setLeftTab] = useState<"manual" | "mdi">("manual");
const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
const [spindleOn, setSpindleOn] = useState(false);
const [coolant, setCoolant] = useState({ mist: false, flood: false });
const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">("p");
const [zoom, setZoom] = useState(1);
const [rotateMode, setRotateMode] = useState(false);
@@ -93,32 +111,59 @@ export function App() {
const [metric, setMetric] = useState(true);
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);
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]);
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
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 axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
const ready = ["ready", "running", "paused"].includes(state.status);
const axisState = { task: { ...(state.task ?? {}), state: state.task?.state ?? (ready ? "ON" : "OFF"), mode: state.task?.mode ?? "MANUAL", interp_state: state.task?.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? state.snapshot?.motion ?? {}, devices: state.devices ?? {} };
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 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");
const isEstop = machineState === "ESTOP";
const machineOn = machineState === "ON";
const ready = machineOn && !["error", "recovering"].includes(state.status);
const programLoaded = Boolean(state.program?.source_hash || state.program?.path || state.program?.loaded || state.program?.program_status && state.program.program_status !== "idle");
const programRunning = operationState === "RUNNING";
const programPaused = operationState === "PAUSED";
const idle = operationState === "IDLE";
const gates = Object.keys(state.gates).length ? state.gates : { load: axisGate("load", axisState), run: axisGate("run", axisState), step: axisGate("step", axisState), pause: axisGate("pause", axisState), resume: axisGate("resume", axisState), abort: axisGate("abort", axisState), mdi: axisGate("mdi", axisState), jog: axisGate("jogStart", axisState), home: axisGate("home", axisState) };
const planner = state.snapshot?.authoritative?.planner;
const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])];
const execute: Action = (kind, payload = {}) => {
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> => {
setCommandStatus(`${kind} pending`);
operationController.execute(kind, payload, { state: axisState }).then(() => setCommandStatus(`${kind} acknowledged`)).catch((error: Error) => {
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 code = error instanceof AxisGateError ? error.code : error.message;
setCommandStatus(`${kind} blocked: ${code}`);
throw error;
});
};
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined); }, 50); };
const stopProgram = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } execute("PROGRAM_ABORT"); };
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; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
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 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 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); };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
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 (key === "f3") { event.preventDefault(); setLeftTab("manual"); return; }
if (key === "f3") { event.preventDefault(); enterManualMode(); return; }
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; }
if (key === "f1") { event.preventDefault(); execute("ESTOP", { active: true }); return; }
if (key === "f2") { event.preventDefault(); execute("IO_RECOVER"); return; }
if (key === "f1") { event.preventDefault(); toggleEstop(); return; }
if (key === "f2") { event.preventDefault(); powerOn(); return; }
if (key === "r") { event.preventDefault(); runProgram(); return; }
if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; }
if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; }
@@ -127,23 +172,29 @@ export function App() {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [axisState, execute, runProgram, stopProgram]);
const onSpindle = (direction: number) => { setSpindleOn(direction !== 0); execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; setCoolant(next); execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
}, [axisState, enterManualMode, powerOn, runProgram, stopProgram, toggleEstop]);
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 (!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);
const coolant = { mist: Boolean((state.deviceIo as Record<string, any> | null)?.coolant?.mist_requested), flood: Boolean((state.deviceIo as Record<string, any> | null)?.coolant?.flood_requested) };
const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
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 taskState = state.axisState?.state ?? String(state.task?.state ?? state.status).toUpperCase();
const machineState = state.axisState?.machine_state ?? "OFF";
const operationState = state.axisState?.operation_state ?? "IDLE";
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)}`;
return <main className="axis-app" data-axis-feature="AXIS-UI-01">
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={() => document.getElementById("axis-program")?.focus()}>Open...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload</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={() => execute("ESTOP", { active: true })}>Toggle Emergency Stop</MenuAction><MenuAction shortcut="F2" onClick={() => execute("IO_RECOVER")}>Toggle Machine Power</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={() => setLeftTab("manual")}>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="Toggle Emergency Stop [F1]" asset="tool_estop.gif" danger onClick={() => execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /><IconButton feature="AXIS-MACHINE-02" label="Toggle Machine power [F2]" asset="tool_power.gif" onClick={() => execute("IO_RECOVER")} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen current file [Ctrl-R]" asset="tool_reload.gif" onClick={() => setProgramText(DEFAULT_PROGRAM)} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!ready} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!ready} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label="Pause / resume execution [P/S]" asset={state.status === "paused" ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={!ready} onClick={() => execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!ready} 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={() => 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}>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 and MDI controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>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></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={ready} /></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={trajectory} view={view} zoom={zoom} rotateMode={rotateMode} showGrid={layers.grid} showTool={layers.tool} 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>
<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} showGrid={layers.grid} showTool={layers.tool} 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">{(["grid", "tool", "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} onLoad={() => execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} currentLine={Number(state.program?.line ?? state.task?.current_line ?? 0) || null} diagnostic={state.error?.code ?? null} />
<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} />
<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>;
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import * as THREE from "three";
type ViewMode = "z" | "z2" | "x" | "y" | "y2" | "p";
type Segment = { start?: Record<string, number>; end?: Record<string, number> };
type Segment = { id?: number; source_line?: number | null; active?: boolean; rapid?: boolean; start?: Record<string, number>; end?: Record<string, number> };
type Props = { axes: number[]; view?: ViewMode; zoom?: number; rotateMode?: boolean; segments?: Segment[]; showGrid?: boolean; showTool?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = false, segments = [], showGrid = true, showTool = true, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
@@ -36,15 +36,32 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals
grid.name = "axis-grid";
scene.add(grid);
scene.add(new THREE.AxesHelper(2.2));
const table = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.18, 3.6), new THREE.MeshStandardMaterial({ color: 0x263740, roughness: 0.8 }));
table.position.y = -0.1;
scene.add(table);
const gantry = new THREE.Mesh(new THREE.BoxGeometry(0.18, 2.2, 0.18), new THREE.MeshStandardMaterial({ color: 0x647c88 }));
gantry.position.set(0, 1, 0);
scene.add(gantry);
const tool = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.55, 16), new THREE.MeshStandardMaterial({ color: 0xc9e86d, emissive: 0x243200 }));
scene.add(tool);
const machine = new THREE.Group();
machine.name = "axis-3axis-tutorial-model";
const frameMaterial = new THREE.MeshStandardMaterial({ color: 0x7d8b91, roughness: 0.82 });
const xMaterial = new THREE.MeshStandardMaterial({ color: 0x3d6fc4, roughness: 0.72 });
const yMaterial = new THREE.MeshStandardMaterial({ color: 0x3f9b69, roughness: 0.72 });
const zMaterial = new THREE.MeshStandardMaterial({ color: 0xd3ba45, roughness: 0.7 });
const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xc9e86d, emissive: 0x243200 });
const sourceScale = 0.01;
const frameAssembly = new THREE.Group(); frameAssembly.name = "frame"; machine.add(frameAssembly);
const base = new THREE.Mesh(new THREE.BoxGeometry(2, 0.2, 5.6), frameMaterial); base.position.set(0, -0.1, 0); frameAssembly.add(base);
const column = new THREE.Mesh(new THREE.BoxGeometry(1.2, 4, 0.6), frameMaterial); column.position.set(0, 2, -2.3); frameAssembly.add(column);
const top = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.4, 1.2), frameMaterial); top.position.set(0, 4.2, -2.2); frameAssembly.add(top);
const yAssembly = new THREE.Group(); yAssembly.name = "yassembly"; machine.add(yAssembly);
const yBase = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2), yMaterial); yBase.position.set(0, 0.25, 0); yAssembly.add(yBase);
const xAssembly = new THREE.Group(); xAssembly.name = "xassembly"; yAssembly.add(xAssembly);
const xBase = new THREE.Mesh(new THREE.BoxGeometry(10, 0.3, 2), xMaterial); xBase.position.set(0, 0.35, 0); xAssembly.add(xBase);
const work = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.12, 1.2), new THREE.MeshStandardMaterial({ color: 0x8f9da4, roughness: 0.9 }));
work.position.set(0, 0.56, 0); xAssembly.add(work);
const zAssembly = new THREE.Group(); zAssembly.name = "zassembly"; machine.add(zAssembly);
const zBody = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1.0, 0.9), zMaterial); zBody.position.set(0, 3.4, 0); zAssembly.add(zBody);
const spindle = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 1.0, 24), zMaterial); spindle.position.set(0, 2.9, 0); zAssembly.add(spindle);
const cat30 = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.3, 0.35, 20), new THREE.MeshStandardMaterial({ color: 0xd15a52 })); cat30.position.set(0, 2.35, 0); zAssembly.add(cat30);
const tool = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.8, 16), toolMaterial); tool.position.set(0, 1.85, 0); zAssembly.add(tool);
scene.add(machine);
const pathGroup = new THREE.Group(); pathGroup.name = "axis-toolpath"; scene.add(pathGroup);
const livePathGroup = new THREE.Group(); livePathGroup.name = "axis-live-toolpath"; scene.add(livePathGroup);
const overlayGroup = new THREE.Group(); overlayGroup.name = "axis-overlays"; scene.add(overlayGroup);
const ambient = new THREE.HemisphereLight(0xc8e4ec, 0x111820, 1.8);
scene.add(ambient);
@@ -65,16 +82,34 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals
let frame = 0;
const render = () => {
const [x = 0, y = 0, z = 0] = axesRef.current;
tool.position.set(THREE.MathUtils.clamp(Number(x) || 0, -2.3, 2.3), 0.35 + THREE.MathUtils.clamp(Number(z) || 0, -1, 2), THREE.MathUtils.clamp(Number(y) || 0, -1.4, 1.4));
const bounds = segmentsRef.current.reduce((box, segment) => {
for (const point of [segment.start ?? {}, segment.end ?? {}]) {
for (const axis of ["x", "y", "z"] as const) {
const value = Number(point[axis] ?? 0);
if (Number.isFinite(value)) { box.min[axis] = Math.min(box.min[axis], value); box.max[axis] = Math.max(box.max[axis], value); }
}
}
return box;
}, { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } });
const span = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z, 1);
const scale = Math.min(0.12, 4.2 / span);
const center = { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2, z: (bounds.min.z + bounds.max.z) / 2 };
xAssembly.position.x = Number(x || 0) * sourceScale;
yAssembly.position.z = Number(y || 0) * sourceScale;
zAssembly.position.y = Number(z || 0) * sourceScale;
grid.visible = layersRef.current.showGrid;
tool.visible = layersRef.current.showTool;
pathGroup.visible = layersRef.current.showPath || layersRef.current.showLive;
pathGroup.visible = layersRef.current.showPath;
livePathGroup.visible = layersRef.current.showLive;
pathGroup.clear();
const material = new THREE.LineBasicMaterial({ color: layersRef.current.showLive ? 0x5fc4d8 : 0x5c80a0 });
livePathGroup.clear();
const completedMaterial = new THREE.LineBasicMaterial({ color: 0x5c80a0 });
const liveMaterial = new THREE.LineBasicMaterial({ color: 0x5fc4d8 });
for (const segment of segmentsRef.current) {
const start = segment.start ?? {}; const end = segment.end ?? {};
const points = [new THREE.Vector3(Number(start.x ?? 0), Number(start.z ?? 0), Number(start.y ?? 0)), new THREE.Vector3(Number(end.x ?? 0), Number(end.z ?? 0), Number(end.y ?? 0))];
pathGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
const points = [new THREE.Vector3((Number(start.x ?? 0) - center.x) * scale, (Number(start.z ?? 0) - center.z) * scale, (Number(start.y ?? 0) - center.y) * scale), new THREE.Vector3((Number(end.x ?? 0) - center.x) * scale, (Number(end.z ?? 0) - center.z) * scale, (Number(end.y ?? 0) - center.y) * scale)];
const target = segment.active ? livePathGroup : pathGroup;
target.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), segment.active ? liveMaterial : completedMaterial));
}
if (layersRef.current.showLimits) {
const limits = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(5, 2, 3.5)), new THREE.LineBasicMaterial({ color: 0xd87d6c }));
@@ -89,7 +124,7 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals
else if (selectedView === "y") camera.position.set(0.01, 2, distance);
else if (selectedView === "y2") camera.position.set(0.01, 2, -distance);
else camera.position.set(6 / Math.max(0.5, zoomRef.current), 5 / Math.max(0.5, zoomRef.current), 7 / Math.max(0.5, zoomRef.current));
camera.lookAt(0, 1, 0);
camera.lookAt(0, 1.8, 0);
scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0;
renderer.render(scene, camera);
frame = requestAnimationFrame(render);

View File

@@ -9,6 +9,7 @@ declare module "*.mjs" {
exportTrace(): Array<Record<string, unknown>>;
}
export class AxisGateError extends Error { code: string; gate: { enabled: boolean; reason: string }; }
export function axisGate(action: string, input?: unknown): { enabled: boolean; reason: string; action: string; taskState?: string; interpState?: string };
}
declare module "*.js" { const createModule: (options?: unknown) => Promise<unknown>; export default createModule; }

View File

@@ -21,8 +21,11 @@ export type MotionSnapshot = {
axes?: number[];
joints?: number[];
velocity?: number[];
homed?: boolean[];
limits?: number;
homed?: boolean | boolean[];
homed_axes?: boolean[];
limits?: number | Record<string, boolean>;
on_any_limit?: boolean;
fault?: string | null;
};
export type TrajectorySnapshot = {
@@ -32,6 +35,7 @@ export type TrajectorySnapshot = {
};
export type SimSnapshotV1 = {
schema?: string;
snapshot_id?: string;
base_snapshot_id?: string;
full?: boolean;
@@ -39,6 +43,7 @@ export type SimSnapshotV1 = {
state_hash: string;
task?: { state: string; mode: string; interp_state?: string; current_line?: number | null };
motion?: MotionSnapshot;
wasm_motion?: Record<string, unknown>;
authoritative?: { state_hash?: string; planner?: TrajectorySnapshot; kinematics?: Record<string, unknown> };
coordinates?: { machine?: number[]; relative?: number[]; g5x?: number; g92?: number[]; tool_offset?: number[] };
spindle?: Array<{ enabled?: boolean; direction?: number; commanded?: number; actual?: number; at_speed?: boolean }>;
@@ -48,6 +53,41 @@ export type SimSnapshotV1 = {
hal?: { graph_revision?: number; changed_values?: ArrayBuffer };
alarms?: Array<{ id?: string; severity?: string; code?: string; acknowledged?: boolean }>;
recovery?: { status?: string; last_verified_seq?: number; checkpoint_tick?: number };
panel_snapshot?: PanelSnapshotV1;
configuration?: Record<string, unknown> | null;
visual?: PanelSnapshotV1["visual"];
};
export type PanelSnapshotV1 = {
schema: "axis-panel-snapshot.v1";
context_id: string;
session_id: string;
simulation_tick: number;
state_hash: string;
task: Record<string, unknown>;
program: Record<string, unknown> | null;
motion: Record<string, unknown>;
coordinates?: { machine?: number[]; relative?: number[]; commanded?: number[] };
overrides?: { feed?: number; spindle?: number[] };
wasm_motion: Record<string, unknown>;
home_limit: Record<string, unknown>;
hal: Record<string, unknown>;
devices: Record<string, unknown>;
device_io: Record<string, unknown>;
configuration: Record<string, unknown> | null;
axis_state: AxisStateView;
gates: Record<string, { action: string; enabled: boolean; reason: string }>;
visual: {
schema: "axis-visual.v1";
pose: Record<string, number>;
joints: number[];
toolpath: Array<Record<string, unknown>>;
active_segment_id?: number | null;
source: "wasm_canonical_motion";
};
authoritative: Record<string, unknown>;
motion_authority: string;
task_authority: string;
};
export type Capabilities = {

View File

@@ -1,4 +1,4 @@
import type { AxisStateView, Capabilities, MessageEnvelope, SimSnapshotV1 } from "./protocol";
import type { AxisStateView, Capabilities, MessageEnvelope, PanelSnapshotV1, SimSnapshotV1 } from "./protocol";
export type SessionStatus = "disconnected" | "handshaking" | "attaching" | "loading" | "ready" | "running" | "paused" | "recovering" | "blocked" | "error";
@@ -22,6 +22,8 @@ export type FrontendState = {
python: Record<string, unknown> | null;
ioAck: Record<string, unknown> | null;
axisState: AxisStateView | null;
gates: Record<string, { action: string; enabled: boolean; reason: string }>;
toolpath: Array<{ id: number; source_line?: number | null; start: Record<string, number>; end: Record<string, number>; active?: boolean }>;
};
export const INITIAL_STATE: FrontendState = {
@@ -44,6 +46,8 @@ export const INITIAL_STATE: FrontendState = {
python: null,
ioAck: null,
axisState: null,
gates: {},
toolpath: [],
};
function payload<T>(message: MessageEnvelope): T {
@@ -51,15 +55,91 @@ function payload<T>(message: MessageEnvelope): T {
}
function projection(result: Record<string, unknown>, message: MessageEnvelope, previous: FrontendState) {
const rawMotion = (result.motion ?? previous.snapshot?.motion ?? {}) as Record<string, unknown>;
const panel = result.panel_snapshot as PanelSnapshotV1 | undefined;
if (panel?.schema === "axis-panel-snapshot.v1") {
const visualSegments = panel.visual.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>;
return {
id: Number(value.id ?? index + 1),
source_line: typeof value.source_line === "number" ? value.source_line : null,
start: value.start as Record<string, number>,
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> } : {}),
...(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 snapshot = {
schema: panel.schema,
simulation_tick: panel.simulation_tick,
state_hash: panel.state_hash,
task: panel.task as SimSnapshotV1["task"],
motion: { ...(panel.motion as SimSnapshotV1["motion"]), axes, joints: panel.visual.joints },
coordinates: panel.coordinates,
overrides: panel.overrides,
wasm_motion: panel.wasm_motion,
authoritative: panel.authoritative as SimSnapshotV1["authoritative"],
configuration: panel.configuration as SimSnapshotV1["configuration"],
visual: panel.visual,
} satisfies SimSnapshotV1;
return {
snapshot,
toolpath: visualSegments,
task: panel.task,
program: panel.program,
devices: panel.devices,
deviceIo: panel.device_io,
hal: panel.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 },
};
}
const wasmMotion = (result.wasm_motion as { state?: Record<string, unknown> } | undefined)?.state;
const rawMotion = (wasmMotion ?? result.motion ?? previous.snapshot?.motion ?? {}) as Record<string, unknown>;
const motionState = (rawMotion.state ?? rawMotion) as Record<string, unknown>;
const axesObject = motionState.axes as Record<string, unknown> | undefined;
const axes = Array.isArray(motionState.axes)
? motionState.axes as number[]
: [axesObject?.x, axesObject?.y, axesObject?.z].map((value) => typeof value === "number" ? value : 0);
const axesObject = (motionState.axes ?? motionState.position) as Record<string, unknown> | undefined;
const interpreterState = (result.state ?? result.interpreter_state) as Record<string, unknown> | undefined;
const axes = result.motion !== undefined
? Array.isArray(motionState.axes)
? motionState.axes as number[]
: axesObject && [axesObject.x, axesObject.y, axesObject.z].some((value) => typeof value === "number")
? [axesObject.x, axesObject.y, axesObject.z].map((value) => typeof value === "number" ? value : 0)
: [0, 0, 0]
: interpreterState && [interpreterState.x, interpreterState.y, interpreterState.z].some((value) => typeof value === "number")
? [interpreterState.x, interpreterState.y, interpreterState.z].map((value, index) => typeof value === "number" ? value : Number(previous.snapshot?.motion?.axes?.[index] ?? 0))
: [Number(previous.snapshot?.motion?.axes?.[0] ?? 0), Number(previous.snapshot?.motion?.axes?.[1] ?? 0), Number(previous.snapshot?.motion?.axes?.[2] ?? 0)];
const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : [];
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] }, authoritative: result.authoritative as SimSnapshotV1["authoritative"] } satisfies SimSnapshotV1;
return { snapshot, task: (result.task as Record<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState };
const authoritative = result.authoritative as SimSnapshotV1["authoritative"];
const programResult = result.program ?? (typeof result.program_status === "string" ? result : previous.program);
const planner = authoritative?.planner;
const preview = result.preview as { status?: string; segments?: Array<Record<string, unknown>> } | undefined;
const previewSegments = preview?.status === "verified" && Array.isArray(preview.segments) ? preview.segments : [];
const plannerSegments = [
...(planner?.queue ?? []),
...(planner?.active ? [planner.active] : []),
].filter((segment): segment is NonNullable<typeof segment> & { id: number; start: Record<string, number>; end: Record<string, number> } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end));
const command = String(result.command ?? "");
const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id));
const nextToolpath = resetPath
? previewSegments.map((segment, index) => ({ id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, ...(segment.center ? { center: segment.center as Record<string, number> } : {}), ...(segment.normal ? { normal: segment.normal as Record<string, number> } : {}), ...(typeof segment.motion_type === "number" ? { motion_type: segment.motion_type } : {}), ...(typeof segment.turn === "number" ? { turn: segment.turn } : {}), active: false }))
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
for (const segment of plannerSegments) {
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
}
if (planner?.active?.id !== undefined) {
for (const segment of nextToolpath) segment.active = segment.id === planner.active?.id;
} else {
for (const segment of nextToolpath) segment.active = false;
}
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, authoritative } satisfies SimSnapshotV1;
return { snapshot, toolpath: nextToolpath, task: (result.task as Record<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates };
}
export function reduceFrontendState(state: FrontendState, message: MessageEnvelope): FrontendState {
@@ -67,7 +147,7 @@ export function reduceFrontendState(state: FrontendState, message: MessageEnvelo
const base = { ...state, contextId: message.context_id, sessionId: message.session_id, lastSeq: Math.max(state.lastSeq, message.seq), tick, error: null };
switch (message.kind) {
case "CAPABILITIES": return { ...base, status: "handshaking", capabilities: payload<Capabilities>(message) };
case "SESSION_READY": return { ...base, status: "ready" };
case "SESSION_READY": { const readyPayload = payload<Record<string, unknown>>(message); return { ...base, status: "ready", task: (readyPayload.task as Record<string, unknown>) ?? state.task, axisState: (readyPayload.axis_state as AxisStateView) ?? state.axisState }; }
case "RECOVERY_STATUS": return { ...base, status: payload<{ status?: string }>(message).status === "verified" ? "ready" : "recovering" };
case "STATE_SNAPSHOT": {
const value = payload<Record<string, unknown>>(message);
@@ -77,13 +157,16 @@ export function reduceFrontendState(state: FrontendState, message: MessageEnvelo
case "COMMAND_RESULT": {
const result = payload<Record<string, unknown>>(message);
const task = result.task as { state?: string } | undefined;
const status = result.status === "blocked" ? "blocked" : task?.state === "RUNNING" ? "running" : state.status === "running" ? "running" : "ready";
const status = result.status === "blocked" || ["ESTOP", "FAULT"].includes(String(task?.state)) ? "blocked" : task?.state === "RUNNING" ? "running" : task?.state === "PAUSED" ? "paused" : "ready";
return { ...base, status, ...projection(result, message, state) };
}
case "ALARM": return { ...base, status: "blocked", alarms: [...state.alarms, payload<Record<string, unknown>>(message)] };
case "ERROR": {
const value = payload<{ code?: string; message_key?: string }>(message);
return { ...base, status: "error", error: { code: value.code ?? "worker_error", message: JSON.stringify(value) } };
const value = payload<Record<string, unknown>>(message);
const projected = projection(value, message, state);
const taskState = String((value.task as Record<string, unknown> | undefined)?.state ?? "").toUpperCase();
const status = ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error";
return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: JSON.stringify(value) }, ...projected };
}
default: return base;
}

View File

@@ -80,6 +80,9 @@ button { color: inherit; }
.check-line { display: inline-flex; align-items: center; gap: 4px; min-height: 25px; margin-top: 6px; }
.check-line input { margin: 0; }
.manual-spindle-block, .manual-coolant-block { min-width: 0; }
.manual-device-block { grid-column: 1 / -1; padding-top: 8px; border-top: 1px solid #b0b0b0; }
.manual-device-block h3 { display: inline; margin-right: 10px; }
.device-status { color: #555; font: 11px ui-monospace, monospace; }
.manual-coolant-block { grid-column: 1; }
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
@@ -102,9 +105,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.three-viewer canvas { display: block; width: 100%; height: 100%; }
.three-viewer-label { position: absolute; z-index: 1; top: 6px; left: 7px; padding: 2px 5px; color: #c9e86d; background: rgb(8 14 19 / 75%); font: 10px ui-monospace, monospace; pointer-events: none; }
.dro-view { background: #ececec; }
.dro-mode-row, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
.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; }
.dro-mode-row span { text-align: right; }
.dro-column-headings { padding: 7px 7px 0; color: #555; font: 10px ui-monospace, monospace; text-align: right; }
.dro-table { margin-top: 9px; border: 1px solid #888; background: #fafafa; }
.dro-row { min-height: 38px; padding: 4px 7px; border-bottom: 1px solid #c6c6c6; }
.dro-row:last-child { border-bottom: 0; }
@@ -117,6 +121,13 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.axis-slider label { grid-template-columns: auto 48px; }
.axis-program-pane { display: grid; grid-template-columns: minmax(0, 1fr) 230px; flex: 0 0 205px; height: 205px; min-height: 205px; margin: 0 5px 5px; border: 2px solid #777; background: #f7f7f7; }
.axis-diagnostics { display: grid; gap: 8px; padding: 10px; min-height: 190px; overflow: auto; background: #f7f7f7; font-size: 12px; }
.diagnostic-summary, .diagnostic-error, .diagnostic-row { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.diagnostic-summary { padding-bottom: 6px; border-bottom: 1px solid #b3b3b3; }
.diagnostic-error { padding: 6px; color: #7b1111; border: 1px solid #c28d8d; background: #f8e0e0; }
.diagnostic-section { display: grid; gap: 3px; }
.diagnostic-section h3 { margin: 0; font-size: 12px; }
.diagnostic-row code { min-width: 160px; color: #26384a; }
.program-editor-wrap { display: grid; grid-template-columns: 34px minmax(0, 1fr); min-width: 0; min-height: 0; }
.program-gutter { display: flex; flex-direction: column; align-items: end; padding: 6px 6px 6px 0; overflow: hidden; border-right: 1px solid #c2c2c2; color: #888; background: #e4e4e4; font: 12px/1.45 ui-monospace, monospace; user-select: none; }
.program-gutter span { height: 17px; }
@@ -127,6 +138,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.active-gcodes-label { display: grid; gap: 5px; color: #333; }
.active-gcodes-label textarea { width: 100%; min-height: 84px; resize: vertical; border: 1px solid #999; background: white; color: #222; font: 11px/1.35 ui-monospace, monospace; }
.program-diagnostic { display: block; padding: 4px; color: #8b1010; background: #f2d6d6; font: 11px ui-monospace, monospace; overflow-wrap: anywhere; }
.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; }
@@ -153,7 +165,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.command-status { margin-left: 0; width: 100%; }
.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-row { grid-template-columns: 27px repeat(3, minmax(0, 1fr)); gap: 4px; }
.dro-mode-row, .dro-column-headings, .dro-row { grid-template-columns: 27px repeat(3, minmax(0, 1fr)); gap: 4px; }
.dro-mode-row { font-size: 9px; }
.dro-row output { font-size: 13px; }
.dro-footer { flex-direction: column; gap: 3px; }

View File

@@ -34,6 +34,9 @@ function linuxcncAssets(): Plugin {
const output = options.dir ? resolve(options.dir) : resolve(repoRoot, "web/react/dist");
const copies = [
["linuxcnc/configs/sim/axis", "linuxcnc/configs/sim/axis"],
["linuxcnc/configs/nc_files", "linuxcnc/configs/nc_files"],
["linuxcnc/nc_files", "linuxcnc/nc_files"],
["linuxcnc/linuxcnc.gif", "linuxcnc/linuxcnc.gif"],
["linuxcnc/share/axis/images", "linuxcnc/share/axis/images"],
["linuxcnc/lib/hallib", "linuxcnc/lib/hallib"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],

View File

@@ -124,18 +124,16 @@ export class AuthoritativeRuntime {
this.state.planner.queue_depth = 0;
return { status: "verified", command: "AUTHORITATIVE_ABORT" };
}
if (this.state.program_status === "completed") {
this.state.status = "completed";
return { status: "verified", command: "AUTHORITATIVE_COMPLETE", queue_depth: this.state.planner.queue_depth };
}
const planner = this.state.planner;
const last = planner.queue.at(-1)?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
const last = planner.queue.at(-1)?.end ?? planner.active?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
if (!samePose(target, last)) {
const id = planner.next_id++;
planner.queue.push({ id, source_line: program.line ?? null, start: clone(this.state.kinematics.pose), end: target });
}
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
this.state.status = this.state.program_status === "completed" ? "completed" : "running";
// M2 marks the interpreter complete, but AXIS remains visibly active until
// the final queued motion reaches its endpoint.
this.state.status = this.state.program_status === "completed" && planner.queue_depth === 0 ? "completed" : "running";
return { status: "verified", command: "AUTHORITATIVE_SYNC", queue_depth: planner.queue_depth };
}

View File

@@ -11,8 +11,10 @@ export const LIMITS = Object.freeze({
export const COMMANDS = Object.freeze([
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY",
"SET_MODE", "SET_OVERRIDE", "JOG_START", "JOG_STOP", "HOME", "ESTOP", "IO_RECOVER", "LIMIT_SET", "FOLLOWING_ERROR",
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "PROBE_CONFIG",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
]);

View File

@@ -23,6 +23,7 @@ export class MotionState {
feed_override: 1,
spindle_override: 1,
homed: false,
homed_axes: [false, false, false],
limits: { x: false, y: false, z: false },
following_error: 0,
jog: null,
@@ -42,8 +43,14 @@ export class MotionState {
if (this.state.estop) return this.fail("motion_estop_active");
if (target !== null && !axis(target)) return this.fail("motion_axis_invalid");
const targets = target === null ? AXES : [axis(target)];
for (const name of targets) { this.state.axes[name] = 0; this.state.joints[AXES.indexOf(name)] = 0; this.state.limits[name] = false; }
this.state.homed = true;
for (const name of targets) {
const index = AXES.indexOf(name);
this.state.axes[name] = 0;
this.state.joints[index] = 0;
this.state.homed_axes[index] = true;
this.state.limits[name] = false;
}
this.state.homed = this.state.homed_axes.every(Boolean);
this.state.fault = null;
this.state.following_error = 0;
this.state.tick += 1;
@@ -57,13 +64,14 @@ export class MotionState {
return this.ok("SET_OVERRIDE");
}
startJog({ axis: requestedAxis, direction = 1, speed = 1 } = {}) {
startJog({ axis: requestedAxis, direction = 1, speed = 1, increment = null } = {}) {
const name = axis(requestedAxis);
if (!name) return this.fail("motion_axis_invalid");
if (!this.state.homed) return this.fail("motion_unhomed");
if (this.state.estop || this.state.fault) return this.fail("motion_faulted");
if (!Number.isSafeInteger(direction) || ![-1, 1].includes(direction) || !finite(speed) || speed <= 0 || speed > MAX_SPEED) return this.fail("motion_jog_range");
this.state.jog = { axis: name, direction, speed };
const jogIncrement = finite(increment) && increment > 0 ? Number(increment) : null;
this.state.jog = { axis: name, direction, speed, ...(jogIncrement === null ? {} : { increment: jogIncrement }) };
this.state.velocity = speed * this.state.feed_override;
return this.ok("JOG_START");
}
@@ -91,6 +99,16 @@ export class MotionState {
return this.ok("FOLLOWING_ERROR");
}
resetFault() {
if (this.state.estop) return this.fail("motion_estop_active");
if (Object.values(this.state.limits).some(Boolean)) return this.fail("motion_limit_active");
this.state.fault = null;
this.state.following_error = 0;
this.state.velocity = 0;
this.state.jog = null;
return this.ok("RESET");
}
estop(active) {
if (typeof active !== "boolean") return this.fail("motion_estop_argument");
this.state.estop = active;
@@ -105,12 +123,27 @@ export class MotionState {
if (!this.state.jog || this.state.estop || this.state.fault) { this.state.velocity = 0; return this.export(); }
const { axis: name, direction, speed } = this.state.jog;
if (this.state.limits[name]) { this.state.fault = "limit"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
const delta = direction * speed * this.state.feed_override * CYCLE_DISTANCE;
const requested = speed * this.state.feed_override * CYCLE_DISTANCE;
const increment = this.state.jog.increment == null ? null : this.state.jog.increment;
const delta = direction * (increment === null ? requested : Math.min(requested, increment));
const next = this.state.axes[name] + delta;
if (!finite(next) || Math.abs(next) > MAX_POSITION) { this.state.fault = "position_range"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
this.state.axes[name] = next;
this.state.joints[AXES.indexOf(name)] = next;
this.state.velocity = Math.abs(speed * this.state.feed_override);
if (increment !== null) {
this.state.jog.increment = increment - Math.abs(delta);
if (this.state.jog.increment <= 1e-12) { this.state.jog = null; this.state.velocity = 0; }
}
return this.export();
}
applyWasmPosition(position, velocity = 0, tick = this.state.tick) {
if (!position || ![position.x, position.y, position.z].every(finite)) return this.fail("motion_wasm_position_invalid");
this.state.axes = { x: position.x, y: position.y, z: position.z };
this.state.joints = [position.x, position.y, position.z];
this.state.velocity = finite(velocity) ? velocity : 0;
this.state.tick = Math.max(this.state.tick, Number.isSafeInteger(tick) ? tick : this.state.tick);
return this.export();
}
@@ -119,7 +152,11 @@ export class MotionState {
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("motion_restore_hash");
if (!finite(value.tick) || !value.axes || !Array.isArray(value.joints) || value.joints.length !== 3) throw new Error("motion_restore_shape");
this.state = clone(unsigned);
const homedAxes = Array.isArray(value.homed_axes)
? value.homed_axes.slice(0, AXES.length).map(Boolean)
: AXES.map(() => value.homed === true);
if (homedAxes.length !== AXES.length) throw new Error("motion_restore_homed_shape");
this.state = { ...clone(unsigned), homed_axes: homedAxes, homed: homedAxes.every(Boolean) };
return this.export();
}
}

View File

@@ -41,6 +41,39 @@ function readInterpreterState(core, pointer) {
};
}
function readInterpreterMotion(core, interpreter, sourceLine = null) {
if (typeof core._sim_interpreter_read_motion !== "function" || typeof core._sim_interpreter_motion_size !== "function") return null;
const size = core._sim_interpreter_motion_size();
if (!Number.isSafeInteger(size) || size < 216 || size > 4096) throw new Error("wasm_motion_size_invalid");
const pointer = core._malloc(size);
if (!pointer) throw new Error("wasm_motion_allocation_failed");
try {
const result = call(core, "sim_interpreter_read_motion", "number", ["number", "number"], [interpreter, pointer]);
if (result !== 0) return null;
const doubles = core.HEAPF64.subarray(pointer / 8, pointer / 8 + 26);
const ints = core.HEAP32.subarray(pointer / 4 + 52, pointer / 4 + 54);
const pose = (offset) => ({
x: doubles[offset], y: doubles[offset + 1], z: doubles[offset + 2],
a: doubles[offset + 3], b: doubles[offset + 4], c: doubles[offset + 5],
u: doubles[offset + 6], v: doubles[offset + 7], w: doubles[offset + 8],
});
return {
source_line: sourceLine,
start: pose(0),
end: pose(9),
center: { x: doubles[18], y: doubles[19], z: doubles[20] },
normal: { x: doubles[21], y: doubles[22], z: doubles[23] },
velocity: doubles[24],
acceleration: doubles[25],
motion_type: ints[0],
turn: ints[1],
source: "wasm_interpreter_canonical_motion",
};
} finally {
core._free(pointer);
}
}
function readCanonical(core, interpreter) {
if (typeof core._sim_interpreter_canonical !== "function") return null;
const required = call(core, "sim_interpreter_canonical", "number", ["number", "number", "number"], [interpreter, 0, 0]);
@@ -328,6 +361,32 @@ export class ProgramRunner {
return this.snapshot();
}
preview() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
const source = this.source;
const path = this.path;
const segments = [];
let result = { status: "verified" };
try {
for (let steps = 0; steps < MAX_EXPANDED_LINES && this.programCounter < this.lines.length; steps += 1) {
result = this.executeNext(true);
if (result.status !== "verified") break;
if (result.motion) {
const motion = result.motion;
const linearNoop = motion.motion_type === 1
&& ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => motion.start[axis] === motion.end[axis]);
if (!linearNoop) segments.push(motion);
}
}
if (result.status !== "verified") return { ...result, preview: { status: "error", segments } };
return { status: "verified", preview: { status: "verified", segments } };
} catch (error) {
return { status: "error", code: error.message, preview: { status: "error", segments } };
} finally {
this.load(source, path);
}
}
pause() {
if (this.status !== "running") return { status: "error", code: "program_not_running" };
this.status = "paused";
@@ -398,7 +457,7 @@ export class ProgramRunner {
if (stateResult !== 0) return { status: "error", code: "mdi_state_read", result: stateResult };
this.state = readInterpreterState(this.core, this.stateBuffer);
const interpreterState = cloneState(this.state);
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, runner: this.snapshot() };
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, motion: readInterpreterMotion(this.core, this.interpreter, this.state.line), runner: this.snapshot() };
} catch (error) {
return { status: "error", code: error.message };
}
@@ -588,7 +647,7 @@ export class ProgramRunner {
}
if (this.state.program_end) this.status = "completed";
else if (this.programCounter >= this.lines.length && !seeking) this.status = "completed";
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status };
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status, motion: readInterpreterMotion(this.core, this.interpreter, index + 1) };
}
this.status = "completed";
return { status: "verified", line: this.currentLine, text: null, state: cloneState(this.state), program_status: this.status };
@@ -618,6 +677,11 @@ export class ProgramRunner {
this.lastError = this.lastError ?? { code: error.message };
}
}
const unitsCode = this.state.units === 20 ? "G20" : "G21";
const distanceCode = this.state.distance_mode === 91 ? "G91" : "G90";
const planeCode = this.state.plane === 18 ? "G18" : this.state.plane === 19 ? "G19" : "G17";
const motionCode = `G${this.state.motion_mode}`;
const spindleCode = this.state.spindle_mode === 5 ? "M5" : `M${this.state.spindle_mode}`;
return {
status: "verified",
program_status: this.status,
@@ -632,6 +696,7 @@ export class ProgramRunner {
modal_hash,
motion_control: { ...this.motionControl },
motion_control_hash: fnv1a64(this.motionControl),
active_gcodes: `${unitsCode} ${distanceCode} ${planeCode} ${motionCode} ${spindleCode}`,
error_column,
error_code,
error: this.lastError,

View File

@@ -21,6 +21,9 @@ import { createTrace, validateTrace } from "./trace-export.mjs";
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
import { axisState } from "./axis-state.mjs";
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
import { axisGate } from "../app/axis-contract.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
@@ -40,6 +43,13 @@ const OPERATION_KINDS = new Set([
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
]);
const PANEL_GATE_ACTIONS = Object.freeze({
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
JOG_START: "jogStart", JOG_STOP: "jogStop", HOME: "home", SET_OVERRIDE: "override",
ESTOP: "estop", IO_RECOVER: "recover",
});
function text(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -90,10 +100,14 @@ export class SimSession {
this.python = new PythonBridge();
this.program = new ProgramRunner(core);
this.authoritative = new AuthoritativeRuntime();
this.wasmMotion = new WasmMotionQueue(core);
this.wasmHomeLimit = new WasmHomeLimit(core);
this.motionAuthority = "js";
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
this.scheduler = new SimScheduler(core);
this.configurationState = null;
this.halState = null;
this.previewState = null;
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
@@ -102,6 +116,7 @@ export class SimSession {
const statePayload = payload && typeof payload === "object" ? payload : {};
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
const task = statePayload.task ?? this.task.export();
const panelSnapshot = this.panelSnapshot({ task, program, input, tick });
return {
schema_version: PROTOCOL_SCHEMA,
kind,
@@ -110,10 +125,70 @@ export class SimSession {
seq: input.seq,
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
simulation_tick: tick,
payload: { ...statePayload, axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }) },
payload: {
...statePayload,
panel_snapshot: panelSnapshot,
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
task_authority: statePayload.task_authority ?? this.task.authority(),
wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read(),
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
},
};
}
panelSnapshot({ task = this.task.export(), program = this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick } = {}) {
const motion = this.motion.export();
const wasmMotion = this.wasmMotion.read();
const homeLimit = this.wasmHomeLimit.read();
const devices = this.devices.export();
const deviceIo = this.deviceIo.export();
const halRuntime = this.hal.export();
const authoritative = this.authoritative.export();
const axis = axisState({ task, program: program ?? {}, action: input.kind, payload: input.payload ?? {} });
const gateInput = { task, program: program ?? {}, motion, devices, taskfile: Boolean(this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateActions = ["load", "run", "step", "pause", "resume", "abort", "restart", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "reset", "spindle", "coolant"];
const gates = Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)]));
const position = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? wasmMotion.state.position
: authoritative.kinematics.pose;
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? [position.x, position.y, position.z]
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
const visual = {
schema: "axis-visual.v1",
pose: { x: position.x, y: position.y, z: position.z },
joints: Array.isArray(joints) ? [...joints] : [],
toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
source: "wasm_canonical_motion",
};
const unsigned = {
schema: "axis-panel-snapshot.v1",
context_id: this.contextId,
session_id: this.sessionId,
simulation_tick: tick,
task,
program,
motion,
coordinates: { machine: [position.x, position.y, position.z], relative: [position.x, position.y, position.z], commanded: [position.x, position.y, position.z] },
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
wasm_motion: wasmMotion,
home_limit: homeLimit,
hal: { configuration: this.halState, runtime: halRuntime },
devices,
device_io: deviceIo,
configuration: this.configurationState,
axis_state: axis,
gates,
visual,
authoritative,
motion_authority: this.motionAuthority,
task_authority: this.task.authority(),
};
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
error(input, code, messageKey, retryable = false) {
return this.envelope("ERROR", input, {
status: "error",
@@ -135,6 +210,23 @@ export class SimSession {
return this.operations.get(input.operation_id) ?? null;
}
commandGate(input) {
const action = PANEL_GATE_ACTIONS[input.kind];
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
const task = this.task.export();
const program = this.program.loaded ? this.program.snapshot() : {};
const motion = this.motion.export();
const devices = this.devices.export();
return axisGate(action, {
task,
program,
motion,
devices,
taskfile: Boolean(this.program.loaded),
kinematicsIdentity: this.motionAuthority === "wasm",
});
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -153,6 +245,11 @@ export class SimSession {
configuration: this.configurationState,
hal: this.halState,
python: this.python.exportState(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
preview: this.previewState,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
@@ -182,6 +279,10 @@ export class SimSession {
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
};
if (this.program.loaded) value.program = this.program.exportState();
return { ...value, state_hash: fnv1a64(value) };
@@ -221,7 +322,17 @@ export class SimSession {
if (trace.state?.task) this.task.restore(trace.state.task);
if (trace.state?.scheduler) this.scheduler.restore(trace.state.scheduler);
if (trace.state?.authoritative) this.authoritative.restore(trace.state.authoritative);
if (trace.state?.motion_authority) this.motionAuthority = trace.state.motion_authority;
if (trace.state?.wasm_motion) this.wasmMotion.restoreState(trace.state.wasm_motion);
if (trace.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(trace.state.wasm_home_limit);
if (trace.state?.motion) this.motion.restore(trace.state.motion);
if (trace.state?.program) {
this.program = new ProgramRunner(this.core);
this.program.restoreState(trace.state.program);
}
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
if (trace.state?.plc) this.plc.restore(trace.state.plc);
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
@@ -235,6 +346,10 @@ export class SimSession {
}
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
}
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
const replayMotion = new MotionState();
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
@@ -256,6 +371,20 @@ export class SimSession {
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
const replayAuthoritative = new AuthoritativeRuntime();
if (checkpoint?.state?.authoritative) replayAuthoritative.restore(checkpoint.state.authoritative);
let replayWasmMotion = null;
let replayHomeLimit = null;
try {
if (replayMotionAuthority === "wasm") {
replayWasmMotion = new WasmMotionQueue(this.core);
replayWasmMotion.restoreState(checkpoint.state.wasm_motion);
if (checkpoint.state.wasm_home_limit) {
replayHomeLimit = new WasmHomeLimit(this.core);
replayHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
}
}
} catch (error) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
}
const result = recoverSimulation({
checkpoint,
contextId: this.contextId,
@@ -263,7 +392,11 @@ export class SimSession {
reconcile,
apply: (state, event) => {
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
if (event.kind === "HOME") replayMotion.home(event.payload?.axis ?? null);
if (event.kind === "HOME") {
replayMotion.home(event.payload?.axis ?? null);
const home = replayHomeLimit?.home(event.seq);
if (home?.status === "error") throw new Error(home.code);
}
if (event.kind === "JOG_START") replayMotion.startJog(event.payload);
if (event.kind === "JOG_STOP") replayMotion.stopJog();
if (event.kind === "SET_OVERRIDE") replayMotion.setOverride(event.payload?.feed ?? event.payload?.feed_override, event.payload?.spindle ?? event.payload?.spindle_override);
@@ -273,23 +406,61 @@ export class SimSession {
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
if (event.kind === "SIM_STEP") replayMotion.step(event.tick);
if (event.kind === "PROGRAM_LOAD") replayProgram.load(event.payload?.source ?? event.payload?.text ?? event.payload?.program_source, event.payload?.path ?? null);
if (event.kind === "PROGRAM_RUN") replayProgram.start();
if (event.kind === "PROGRAM_PAUSE") replayProgram.pause();
if (event.kind === "PROGRAM_RESUME") replayProgram.resume();
if (event.kind === "PROGRAM_ABORT") replayProgram.abort();
if (event.kind === "PROGRAM_RESTART") replayProgram.restart();
if (event.kind === "PROGRAM_RUN") { replayProgram.start(); replayWasmMotion?.setPaused(false); }
if (event.kind === "PROGRAM_PAUSE") { replayProgram.pause(); replayWasmMotion?.setPaused(true); }
if (event.kind === "PROGRAM_RESUME") { replayProgram.resume(); replayWasmMotion?.setPaused(false); }
if (event.kind === "PROGRAM_ABORT") { replayProgram.abort(); replayWasmMotion?.abort(); }
if (event.kind === "PROGRAM_RESTART") { replayProgram.restart(); replayWasmMotion?.reset(); }
if (event.kind === "SIM_STEP" && replayProgram.loaded) {
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
replayProgram.tick(1);
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
if (synced.status === "blocked") throw new Error(synced.code);
const stepped = replayAuthoritative.step(replayTick);
if (stepped.status === "error") throw new Error(stepped.code);
// PROGRAM_LOAD resets the planner clock, while the session clock is
// monotonic. Recovery must start this replay at the checkpoint tick
// or one logical step would consume the whole historical interval.
if (replayMotionAuthority === "wasm") {
if (!replayWasmMotion) throw new Error("wasm_motion_replay_unavailable");
const queueTick = replayWasmMotion.read().state.tick;
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
const cycles = Number.isSafeInteger(event.payload?.cycles) && event.payload.cycles >= 0 ? event.payload.cycles : 1;
for (let cycle = 0; cycle < cycles; cycle += 1) {
const replayTick = queueTick + cycle + 1;
const steps = replayProgram.tick(1);
for (const step of steps) {
if (!step.motion) continue;
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
if (!linearNoop) {
const enqueue = replayWasmMotion.enqueue(step.motion);
if (enqueue.status === "error") throw new Error(enqueue.code);
}
}
const stepped = replayWasmMotion.step();
if (stepped.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
if (stepped.status === "verified") {
const projected = replayMotion.applyWasmPosition(stepped.state.position, stepped.state.velocity, replayTick);
if (projected.status === "error") throw new Error(projected.code);
if (replayHomeLimit?.read().state.homed) {
const checked = replayHomeLimit.check([stepped.state.position.x, stepped.state.position.y, stepped.state.position.z]);
if (checked.status === "error") throw new Error(checked.code);
}
}
}
} else {
if (Number.isSafeInteger(state.tick) && replayAuthoritative.state.tick < state.tick) replayAuthoritative.state.tick = state.tick;
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
replayProgram.tick(1);
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
if (synced.status === "blocked") throw new Error(synced.code);
const stepped = replayAuthoritative.step(replayTick);
if (stepped.status === "error") throw new Error(stepped.code);
}
}
} else if (event.kind === "SIM_STEP") {
const stepped = replayAuthoritative.step(event.tick);
if (stepped.status === "error") throw new Error(stepped.code);
if (replayMotionAuthority === "wasm") {
const stepped = replayWasmMotion?.step();
if (stepped?.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
} else {
const stepped = replayAuthoritative.step(event.tick);
if (stepped.status === "error") throw new Error(stepped.code);
}
}
if (event.kind === "PLC_CONFIG") replayPlc.configure(event.payload);
if (event.kind === "PLC_SET") replayPlc.setRegister(event.payload?.register, event.payload?.value);
@@ -352,7 +523,7 @@ export class SimSession {
const programStatus = replayProgram.status === "completed" ? "completed" : replayProgram.status === "running" ? "running" : replayProgram.status === "paused" ? "paused" : replayProgram.status === "aborted" ? "aborted" : "loaded";
task.current_line = replayProgram.currentLine;
task.program_status = programStatus;
if (programStatus === "completed" && task.state === "RUNNING") task.state = "STOPPED";
if (programStatus === "completed" && task.state === "RUNNING" && (replayMotionAuthority !== "wasm" || replayWasmMotion?.read().state.queue_depth === 0)) task.state = "STOPPED";
}
const schedulerUnsigned = {
schema: "sim_scheduler.v1",
@@ -365,11 +536,16 @@ export class SimSession {
last_cycles: event.kind === "SIM_STEP" && Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : (state.scheduler?.last_cycles ?? 0),
state_hash: fnv1a64(schedulerUnsigned),
};
return { ...state, tick: event.tick, task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
return { ...state, tick: event.tick, motion_authority: replayMotionAuthority, task_authority: replayMotionAuthority === "wasm" ? "wasm_task_canonical" : "js_compat", task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), ...(replayWasmMotion ? { wasm_motion: replayWasmMotion.exportState() } : {}), ...(replayHomeLimit ? { wasm_home_limit: replayHomeLimit.exportState() } : {}), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
},
});
replayWasmMotion?.destroy();
replayHomeLimit?.destroy();
if (result.status === "recovered" || result.status === "aborted") {
this.tick = result.simulation_tick;
this.motionAuthority = result.state?.motion_authority ?? checkpoint?.state?.motion_authority ?? this.motionAuthority;
if (this.motionAuthority === "wasm" && result.state?.wasm_motion) this.wasmMotion.restoreState(result.state.wasm_motion);
if (this.motionAuthority === "wasm" && result.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(result.state.wasm_home_limit);
if (result.state?.task) this.task.restore(result.state.task);
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
this.authoritative = new AuthoritativeRuntime();
@@ -377,7 +553,11 @@ export class SimSession {
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
this.motion = new MotionState();
if (result.state?.motion) this.motion.restore(result.state.motion);
this.authoritative.applyMotion(this.motion);
if (this.motionAuthority !== "wasm") this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm") {
const wasmMotion = this.wasmMotion.read();
if (wasmMotion.status !== "verified" || !result.state?.motion || fnv1a64(result.state.motion.axes) !== fnv1a64(wasmMotion.state.position)) throw new Error("wasm_motion_recovery_projection_mismatch");
}
this.plc = new PlcRuntime();
if (result.state?.plc) this.plc.restore(result.state.plc);
this.hal = new HalRuntime();
@@ -392,6 +572,7 @@ export class SimSession {
if (result.state?.callbacks) this.callbacks.restore(result.state.callbacks);
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core);
@@ -478,6 +659,28 @@ export class SimSession {
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
this.operations.set(input.operation_id, { operation_id: input.operation_id, status: "executing", seq: input.seq, kind: input.kind, result_hash: null });
}
const gate = this.commandGate(input);
const handlerOwnsFault = (input.kind === "JOG_START" || input.kind === "JOG_STOP") && Boolean(this.motion.state.fault);
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && this.program.status === "completed";
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion) {
if (text(input.operation_id)) this.operations.delete(input.operation_id);
const rejectedPayload = { command: input.kind, gate_reason: gate.reason, payload: input.payload ?? {} };
try {
this.timeline.appendEvent(input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
const rejectedEvent = appendCoreEvent(this.core, input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
if (rejectedEvent !== 0) return this.error(input, "event_log", "core_event_append_failed");
} catch (error) {
return this.error(input, "event_log", String(error?.message ?? error));
}
return this.envelope("ERROR", input, {
status: "blocked",
code: "axis_gate",
message_key: gate.reason,
gate_reason: gate.reason,
retryable: false,
operation_id: input.operation_id ?? null,
});
}
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
? this.tick + input.payload.cycles : this.tick;
@@ -560,6 +763,19 @@ export class SimSession {
}
try { this.task.transition("READY"); } catch (error) { return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("SESSION_READY", input, { state_hash: this.task.hash(), task: this.task.export(), subscriptions: [] }), "verified");
case "SET_MODE": {
const mode = String(input.payload?.mode ?? "").toUpperCase();
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
}
case "RESET": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
const result = this.motion.resetFault();
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
if (this.task.state.state === "FAULT" || this.task.state.state === "STOPPED") this.task.recover();
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motion.export(), operation_id: input.operation_id ?? null }));
}
case "LOAD_WORKSPACE": {
const rootPath = input.payload?.root_path;
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
@@ -588,7 +804,7 @@ export class SimSession {
startup: startupReport,
}), "failed");
}
this.configurationState = startupReport;
this.configurationState = startupReport;
this.created = true;
startupLoaded = true;
} else {
@@ -610,6 +826,11 @@ export class SimSession {
};
}
}
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") ? "wasm" : "js";
if (configuration?.home_limits && this.motionAuthority === "wasm") {
this.wasmHomeLimit.destroy();
this.wasmHomeLimit = new WasmHomeLimit(this.core, { limits: configuration.home_limits });
}
if (input.payload?.hal) {
const preflight = preflightHalConfiguration(input.payload.hal);
if (preflight.status !== "ready") {
@@ -636,6 +857,7 @@ export class SimSession {
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, {
status: "verified",
command: input.kind,
motion_authority: this.motionAuthority,
state_hash: this.task.hash(),
task: this.task.export(),
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
@@ -648,19 +870,30 @@ export class SimSession {
let program_steps = [];
try {
this.scheduler.step(cycles, {
task: () => {
program_steps.push(...this.program.tick(1));
task: () => {
const steps = this.program.tick(1);
program_steps.push(...steps);
for (const step of steps) {
if (!step.motion) continue;
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
if (!linearNoop) {
const enqueue = this.wasmMotion.enqueue(step.motion);
if (enqueue.status === "error") throw new Error(enqueue.code);
}
}
},
tp: (nextTick) => {
if (this.program.loaded) {
if (this.motionAuthority !== "wasm" && this.program.loaded) {
const sync = this.authoritative.syncProgram(this.program.snapshot());
if (sync.status === "blocked") throw new Error(sync.code);
}
const planner = this.authoritative.step(nextTick);
if (planner.status === "error") throw new Error(planner.code);
if (this.motionAuthority !== "wasm") {
const planner = this.authoritative.step(nextTick);
if (planner.status === "error") throw new Error(planner.code);
}
},
kinematics: () => {
if (!["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
if (this.motionAuthority !== "wasm" && !["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
},
motion: (nextTick) => {
const stepResult = call(this.core, "sim_step", 1);
@@ -668,8 +901,19 @@ export class SimSession {
this.tick = nextTick;
this.task.setTick(nextTick);
const motionState = this.motion.step(nextTick);
const wasmMotion = this.wasmMotion.step();
if (wasmMotion.status === "error" && wasmMotion.code !== "wasm_motion_step_-605") throw new Error(wasmMotion.code);
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm") {
if (wasmMotion.status === "verified") {
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
if (position.status === "error") throw new Error(position.code);
}
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed && !motionState.fault) {
const homeCheck = this.wasmHomeLimit.check(this.motion.state.joints);
if (homeCheck.status === "error") throw new Error(homeCheck.code);
}
const halState = this.hal.step(nextTick);
if (halState.status === "error") throw new Error(halState.code);
const deviceIoState = this.deviceIo.step(nextTick);
@@ -692,12 +936,12 @@ export class SimSession {
if (program) {
const status = program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status;
this.task.setProgram(status, program.line);
if (status === "completed" && this.task.state.state === "RUNNING") this.task.transition("STOPPED");
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
}
const snapshotState = { tick: this.tick, cycles, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
if (program) snapshotState.program = program;
const snapshot = this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
}
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
@@ -705,11 +949,20 @@ export class SimSession {
const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
this.wasmMotion.reset();
this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
this.task.setProgram("loaded", 0);
const preview = this.program.preview();
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = result.preview;
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -719,12 +972,16 @@ export class SimSession {
if (result.status === "verified") {
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
this.task.transition("RUNNING");
this.task.setProgram("running", this.program.currentLine);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -733,13 +990,25 @@ export class SimSession {
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
const result = this.program[action]();
if (result.status === "verified") {
if (action === "pause") this.task.transition("PAUSED");
else if (action === "resume") this.task.transition("RUNNING");
else { this.authoritative.abort(); this.task.transition("STOPPED"); this.task.setProgram("aborted", this.program.currentLine); }
if (action === "pause") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
this.task.transition("PAUSED");
} else if (action === "resume") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
this.task.transition("RUNNING");
} else {
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
else this.authoritative.abort();
this.task.transition("STOPPED");
this.task.setProgram("aborted", this.program.currentLine);
}
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -748,9 +1017,13 @@ export class SimSession {
const result = this.program.restart();
if (result.status === "verified") {
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setMode("AUTO");
this.authoritative.reset();
this.wasmMotion.reset();
this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
this.task.setProgram("loaded", 0);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
@@ -767,14 +1040,26 @@ export class SimSession {
}
const result = this.program.step();
if (result.status === "verified") {
if (this.motionAuthority === "wasm" && result.motion) {
const linearNoop = result.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => result.motion.start[axis] === result.motion.end[axis]);
if (!linearNoop) {
const enqueue = this.wasmMotion.enqueue(result.motion);
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
}
this.wasmMotion.setPaused(true);
}
this.task.setProgram(result.program_status === "completed" ? "completed" : "paused", result.line);
if (result.program_status === "completed") {
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
const pending = this.motionAuthority === "wasm" ? this.wasmMotion.read().state.queue_depth > 0 : this.authoritative.state.planner.queue_depth > 0;
if (this.task.state.state === "RUNNING") this.task.transition(pending ? "PAUSED" : "STOPPED");
} else if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -803,16 +1088,21 @@ export class SimSession {
}
case "HOME": {
const result = this.motion.home(input.payload?.axis ?? null);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
if (result.status === "verified" && this.motionAuthority === "wasm") {
const home = this.wasmHomeLimit.home(input.seq);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
let result;
if (input.kind === "JOG_START" && this.motionAuthority === "wasm" && !this.wasmHomeLimit.read().state.homed) return finish(this.error(input, "motion_state", "motion_unhomed"), "failed");
if (input.kind === "JOG_START") result = this.motion.startJog(input.payload);
else if (input.kind === "JOG_STOP") result = this.motion.stopJog();
else if (input.kind === "SET_OVERRIDE") result = this.motion.setOverride(input.payload?.feed ?? input.payload?.feed_override, input.payload?.spindle ?? input.payload?.spindle_override);
else result = this.motion.setFollowingError(input.payload?.value ?? input.payload?.following_error);
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":

View File

@@ -74,6 +74,7 @@ export class SimTask {
if (!this.coreTask) return;
const result = coreCall(this.core, name, "number", ["number", ...args.map(() => "number")], [this.coreTask, ...args]);
if (result !== 0) throw new Error(`${code}_${result}`);
this.syncFromCore();
}
mirrorCommand(seq, tick) {
@@ -81,6 +82,36 @@ export class SimTask {
if (!this.coreTask) return;
const result = coreCall(this.core, "sim_task_set_command", "number", ["number", "number", "number"], [this.coreTask, seq, tick]);
if (result !== 0) throw new Error(`task_command_${result}`);
this.syncFromCore();
}
syncFromCore() {
if (!this.coreTask || typeof this.core?._sim_task_canonical !== "function") return false;
const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]);
if (!Number.isSafeInteger(required) || required <= 1 || required > 4096) throw new Error("task_core_canonical_invalid");
const pointer = this.core._malloc(required);
if (!pointer) throw new Error("task_core_canonical_allocation_failed");
try {
const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]);
const canonical = JSON.parse(readCoreString(this.core, pointer, written));
if (canonical.schema !== TASK_SCHEMA || canonical.context_id !== this.contextId || canonical.session_id !== this.sessionId) throw new Error("task_core_canonical_context");
this.state = {
...this.state,
state: canonical.state,
mode: canonical.mode,
program_status: canonical.program_status,
seq: canonical.seq,
simulation_tick: canonical.simulation_tick,
current_line: canonical.current_line,
};
return true;
} finally {
this.core._free(pointer);
}
}
authority() {
return this.coreTask && typeof this.core?._sim_task_canonical === "function" ? "wasm_task_canonical" : "js_compat";
}
setCommand(seq, tick) {
@@ -140,6 +171,7 @@ export class SimTask {
}
export() {
this.syncFromCore();
const unsigned = clone(this.state);
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}

View File

@@ -11,6 +11,7 @@ const EXPECTED_FILES = [
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial_mm.var",
"linuxcnc/configs/nc_files/3dtest.ngc",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl",
"linuxcnc/linuxcnc.gif",
];
@@ -30,6 +31,7 @@ assert.deepEqual(bundle.missing, []);
assert.deepEqual(bundle.optional_missing, []);
assert.equal(bundle.catalog.path, SELECTED_CASE);
assert.equal(bundle.catalog.coordinates, "X Y Z");
assert.ok(bundle.files["linuxcnc/configs/nc_files/3dtest.ngc"].includes("start xy circle"));
for (const path of EXPECTED_FILES) assert.ok(Object.hasOwn(bundle.files, path), `missing exact case file: ${path}`);
assert.equal(bundle.files["linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl"].includes("T1"), true);

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "axis3-visible-completion" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG1 Z-1 F60\nG0 X0 Y0 Z0\nM2\n";
send("PROGRAM_LOAD", "program", { source, path: "visible-completion.ngc" });
send("PROGRAM_RUN", "run");
let finish = null;
let sawWasmMotionAuthority = false;
for (let index = 0; index < 512; index += 1) {
finish = send("SIM_STEP", `step-${index}`, { cycles: 1 });
const wasmPosition = finish.payload.wasm_motion?.state?.position;
const authoritativePosition = finish.payload.authoritative?.kinematics?.pose;
if (wasmPosition && authoritativePosition && (wasmPosition.x !== authoritativePosition.x || wasmPosition.y !== authoritativePosition.y || wasmPosition.z !== authoritativePosition.z)) sawWasmMotionAuthority = true;
if (finish.payload.program?.program_status === "completed" && finish.payload.task?.state === "STOPPED" && finish.payload.wasm_motion?.state?.queue_depth === 0) break;
}
assert.equal(finish.payload.program.program_status, "completed");
assert.equal(finish.payload.task.state, "STOPPED");
assert.equal(finish.payload.authoritative.kinematics.pose.x, 0);
assert.equal(finish.payload.authoritative.kinematics.pose.y, 0);
assert.equal(finish.payload.authoritative.kinematics.pose.z, 0);
assert.equal(finish.payload.motion.axes.x, 0);
assert.equal(finish.payload.motion.axes.y, 0);
assert.equal(finish.payload.motion.axes.z, 0);
assert.equal(finish.payload.wasm_motion.state.source, "wasm_motion_queue");
assert.equal(finish.payload.wasm_motion.state.queue_depth, 0);
assert.equal(sawWasmMotionAuthority, true);
console.log(JSON.stringify({ schema: "axis3-visible-completion.v1", status: "verified", case: CASE, final_pose: finish.payload.authoritative.kinematics.pose, task_state: finish.payload.task.state, program_status: finish.payload.program.program_status }));

View File

@@ -16,6 +16,11 @@ assert.equal(twin.setFollowingError(0.02).state.fault, "following_error");
const checkpoint = twin.export();
const restoredTwin = new MotionState();
assert.deepEqual(restoredTwin.restore(checkpoint), checkpoint);
const partialHome = new MotionState();
assert.deepEqual(partialHome.home("z").state.homed_axes, [false, false, true]);
assert.equal(partialHome.state.homed, false);
assert.deepEqual(partialHome.home("x").state.homed_axes, [true, false, true]);
assert.equal(partialHome.home("y").state.homed, true);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "motion-state" });
@@ -25,6 +30,7 @@ send("HELLO", 1);
send("SESSION_ATTACH", 2, "attach");
send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("HOME", 4, "home").payload.state.homed, true);
assert.deepEqual(session.motion.export().homed_axes, [true, true, true]);
send("JOG_START", 5, "jog", { axis: "x", direction: 1, speed: 10 });
const stepped = send("SIM_STEP", 6, "step", { cycles: 2 });
assert.equal(stepped.payload.snapshot.state_hash, stepped.payload.state_hash);

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "panel-gates" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const noProgram = send("PROGRAM_RUN", "run-without-program");
assert.equal(noProgram.kind, "ERROR");
assert.equal(noProgram.payload.code, "axis_gate");
assert.equal(noProgram.payload.gate_reason, "axis_taskfile_missing");
send("PROGRAM_LOAD", "load", { path: "gate.ngc", source: "G21 G90\nG1 X1 F10\nM2\n" });
const running = send("PROGRAM_RUN", "run");
assert.equal(running.payload.task.state, "RUNNING");
const homeWhileRunning = send("HOME", "home-while-running", { axis: "x" });
assert.equal(homeWhileRunning.kind, "ERROR");
assert.equal(homeWhileRunning.payload.code, "axis_gate");
assert.equal(homeWhileRunning.payload.gate_reason, "axis_manual_mode_required");
const mdiWhileRunning = send("MDI_EXECUTE", "mdi-while-running", { block: "G0 X2" });
assert.equal(mdiWhileRunning.kind, "ERROR");
assert.equal(mdiWhileRunning.payload.message_key, "mdi_while_program_active");
send("PROGRAM_ABORT", "abort");
send("SET_MODE", "manual", { mode: "MANUAL" });
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
send("HOME", "home-z", { axis: "z" });
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 1 });
assert.equal(jog.payload.status, "verified");
send("JOG_STOP", "jog-stop");
const estop = send("ESTOP", "estop", { active: true });
assert.equal(estop.kind, "ERROR");
const blockedRun = send("PROGRAM_RUN", "run-estop");
assert.equal(blockedRun.kind, "ERROR");
assert.ok(["axis_estop_active", "axis_fault_active"].includes(blockedRun.payload.gate_reason));
send("ESTOP", "estop-reset", { active: false });
send("IO_RECOVER", "recover");
const reset = send("RESET", "reset");
assert.equal(reset.payload.status, "verified");
console.log(JSON.stringify({
schema: "panel-gate-matrix.v1",
status: "verified",
normal: ["PROGRAM_LOAD", "PROGRAM_RUN", "SET_MODE", "HOME", "JOG_START", "JOG_STOP", "RESET"],
blocked: [noProgram.payload.gate_reason, homeWhileRunning.payload.gate_reason, blockedRun.payload.gate_reason, mdiWhileRunning.payload.message_key],
authority: "worker_axis_gate",
}));

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "panel-snapshot" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const projection = loaded.payload.panel_snapshot;
assert.equal(projection.schema, "axis-panel-snapshot.v1");
assert.equal(projection.context_id, CONTEXT_ID);
assert.equal(projection.session_id, session.sessionId);
assert.equal(projection.motion_authority, "wasm");
assert.equal(projection.task_authority, "wasm_task_canonical");
assert.equal(projection.axis_state.state, "IDLE");
assert.equal(projection.gates.run.enabled, false);
assert.equal(projection.gates.run.reason, "axis_taskfile_missing");
assert.equal(projection.configuration.state, "TASK_READY");
assert.equal(projection.home_limit.state.source, "wasm_home_limit");
assert.equal(projection.visual.source, "wasm_canonical_motion");
assert.deepEqual(projection.visual.pose, { x: 0, y: 0, z: 0 });
const loadedProgram = send("PROGRAM_LOAD", "program", { path: "panel.ngc", source: "G21 G90\nG0 X1 Y2 Z-1\nM2\n" });
assert.equal(loadedProgram.payload.panel_snapshot.program.program_status, "loaded");
assert.ok(loadedProgram.payload.panel_snapshot.visual.toolpath.length > 0);
const beforeHash = loadedProgram.payload.panel_snapshot.state_hash;
send("HOME", "home-z", { axis: "z" });
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
const stepped = send("SIM_STEP", "jog-step", { cycles: 2 });
assert.notEqual(stepped.payload.panel_snapshot.state_hash, beforeHash);
assert.equal(stepped.payload.panel_snapshot.visual.source, "wasm_canonical_motion");
assert.equal(stepped.payload.panel_snapshot.simulation_tick, stepped.simulation_tick);
assert.equal(stepped.payload.panel_snapshot.state_hash, session.panelSnapshot({ input: { kind: "SIM_STEP", payload: { cycles: 2 } }, tick: session.tick }).state_hash);
assert.ok(jog.payload.panel_snapshot.home_limit.state.homed === true);
console.log(JSON.stringify({
schema: "panel-snapshot-projection.v1",
status: "verified",
case: CASE,
fields: ["task", "program", "motion", "wasm_motion", "home_limit", "hal", "devices", "device_io", "configuration", "axis_state", "visual", "state_hash"],
authority: "wasm",
startup: projection.configuration.state,
visual_source: projection.visual.source,
state_hash: stepped.payload.panel_snapshot.state_hash,
}));

View File

@@ -4,6 +4,7 @@ import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
import { loadSimulationCase } from "../workspace/sim-case-loader.mjs";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { startConfiguration } from "../workspace/configuration-startup.mjs";
import { classifyMissingResources } from "../workspace/linuxcnc-source-semantics.mjs";
const fetchImpl = async (url) => {
try {
@@ -25,7 +26,7 @@ async function matrixRun() {
}
const first = startup?.diagnostics?.[0] ?? null;
const classification = bundle.status !== "ready"
? "BLOCKED_RESOURCE"
? classifyMissingResources(bundle.missing)
: startup.status === "ready"
? "TASK_READY"
: first?.code === "startup_hal_blocked"
@@ -59,7 +60,9 @@ async function matrixRun() {
const first = await matrixRun();
const second = await matrixRun();
assert.equal(first.case_count, 235);
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 12, BLOCKED_STARTUP: 3, TASK_READY: 95 });
// LinuxCNC treats DISPLAY/OPEN_FILE as an optional AXIS startup hint. Missing
// HALFILEs remain BLOCKED_HAL, while genuine absent resources remain blocked.
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 11, BLOCKED_STARTUP: 3, TASK_READY: 96 });
assert.deepEqual(first, second);
for (const path of [
"linuxcnc/configs/sim/axis/minimal_xyz.ini",
@@ -67,6 +70,7 @@ for (const path of [
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini",
]) assert.equal(first.cases.find((row) => row.path === path).classification, "TASK_READY");
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
assert.equal(first.cases.find((row) => row.path.endsWith("craftsmancnc-axis.ini")).classification, "BLOCKED_HAL");
console.log(JSON.stringify(first));

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-canonical-state" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
assert.equal(loaded.payload.wasm_home_limit.state.source, "wasm_home_limit");
const taskBefore = session.task.export();
session.task.state.state = "FAULT";
const canonicalSnapshot = send("SNAPSHOT_REQUEST", "canonical-snapshot");
assert.equal(canonicalSnapshot.payload.task.state, taskBefore.state);
assert.equal(canonicalSnapshot.payload.task.mode, taskBefore.mode);
assert.equal(canonicalSnapshot.payload.task.program_status, taskBefore.program_status);
assert.equal(canonicalSnapshot.payload.task_authority, "wasm_task_canonical");
const home = send("HOME", "home-z", { axis: "z" });
assert.equal(home.payload.wasm_home_limit.state.source, "wasm_home_limit");
assert.equal(home.payload.wasm_home_limit.state.homed, true);
assert.equal(home.payload.wasm_home_limit.state.home_sequence, 5);
assert.equal(home.payload.wasm_home_limit.state.last_error, 0);
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
const stepped = send("SIM_STEP", "jog-step", { cycles: 3 });
assert.equal(stepped.payload.task_authority, "wasm_task_canonical");
assert.equal(stepped.payload.wasm_home_limit.state.source, "wasm_home_limit");
assert.ok(stepped.payload.wasm_home_limit.state.tick > home.payload.wasm_home_limit.state.tick);
assert.deepEqual(stepped.payload.motion.axes, { x: stepped.payload.motion.joints[0], y: stepped.payload.motion.joints[1], z: stepped.payload.motion.joints[2] });
const limit = send("LIMIT_SET", "limit", { axis: "x", active: true });
assert.equal(limit.payload.wasm_home_limit.state.source, "wasm_home_limit");
console.log(JSON.stringify({
schema: "wasm-canonical-state-t4.v1",
status: "verified",
case: CASE,
task_authority: loaded.payload.task_authority,
home_authority: loaded.payload.wasm_home_limit.state.source,
home_sequence: home.payload.wasm_home_limit.state.home_sequence,
home_tick: home.payload.wasm_home_limit.state.tick,
motion_tick: stepped.payload.motion.tick,
home_limit_tick: stepped.payload.wasm_home_limit.state.tick,
js_task_tamper_ignored: canonicalSnapshot.payload.task.state === taskBefore.state,
}));

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { fnv1a64 } from "./replay.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function create(id) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: id });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
send("HOME", "home", { axis: "z" });
send("PROGRAM_LOAD", "program", { path: "canonical-state.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X20 Y10 F120\nG1 Z-1 F60\nM2\n" });
send("PROGRAM_RUN", "run");
return { session, send };
}
const continuous = await create("wasm-canonical-checkpoint");
continuous.send("SIM_STEP", "step-a", { cycles: 3 });
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.task_authority, "wasm_task_canonical");
assert.equal(checkpoint.state.wasm_home_limit.schema, "sim_home_limit_state.v1");
continuous.send("SIM_STEP", "step-b", { cycles: 4 });
const continuousState = continuous.session.workspacePackageState();
const recovered = await create("wasm-canonical-checkpoint");
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredState = recovered.session.workspacePackageState();
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
assert.equal(recoveredState.task.state_hash, continuousState.task.state_hash);
assert.equal(recoveredState.wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
const trace = continuous.session.exportTrace();
const traced = await create("wasm-canonical-checkpoint");
assert.equal(traced.session.importTrace(trace).status, "verified");
assert.equal(traced.session.workspacePackageState().wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
const damaged = structuredClone(checkpoint);
damaged.state.wasm_home_limit.state_bytes = `${damaged.state.wasm_home_limit.state_bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_home_limit.state_bytes.slice(2)}`;
damaged.state_hash = fnv1a64(damaged.state);
assert.equal(recovered.session.restoreCheckpoint(damaged, null, (operation) => operation).status, "blocked");
console.log(JSON.stringify({
schema: "wasm-canonical-state-checkpoint-t4.v1",
status: "verified",
case: CASE,
checkpoint_hash: checkpoint.checkpoint_hash,
continuous_hash: fnv1a64(continuousState),
recovered_hash: fnv1a64(recoveredState),
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
trace_import: "verified",
damaged_home_state: "blocked",
}));

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-feature-matrix-t5.report.json", import.meta.url), "utf8"));
assert.equal(report.schema, "wasm-feature-matrix-t5.v1");
assert.equal(report.task_id, "T5-FULL-MATRIX-01");
assert.equal(report.context_id, "linuxcnc-2.10.0-pre0-wasm-m1-py-c1");
assert.equal(report.matrix.length, 9);
assert.equal(report.status, "blocked");
assert.equal(report.release_action, "STOP_RELEASE");
assert.equal(report.candidate_eligible, false);
assert.deepEqual(report.counts.statuses, { blocked: 9, queued: 0, verified: 0 });
for (const row of report.matrix) {
assert.ok(row.owner);
assert.ok(row.inputs.length > 0);
assert.ok(row.tests.length > 0);
assert.ok(row.differences.length > 0);
assert.ok(row.stop_reason);
assert.ok(["verified", "blocked", "queued"].includes(row.status));
}
assert.equal(report.checks.no_selected_case_promotion.status, "verified");
const selfHash = report.report_sha256;
const canonical = (value) => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const copy = structuredClone(report);
delete copy.report_sha256;
assert.equal(selfHash, createHash("sha256").update(canonical(copy)).digest("hex"));
console.log(JSON.stringify({
schema: report.schema,
status: "verified",
task_id: report.task_id,
domains: report.matrix.length,
blocked_domains: report.counts.statuses.blocked,
blocked_tests: report.counts.test_blocked,
release_action: report.release_action,
}));

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-function-closure-t6.report.json", import.meta.url), "utf8"));
assert.equal(report.schema, "wasm-function-closure-t6.v1");
assert.equal(report.task_id, "T6-FUNCTION-CLOSURE-01");
assert.equal(report.status, "blocked");
assert.equal(report.matrix.length, 8);
assert.equal(report.counts.blocked, 8);
assert.equal(report.counts.test_blocked, 0);
assert.equal(report.failure_samples.length, 4);
assert.ok(report.failure_samples.some((sample) => sample.code === "official_kinematics_oracle_missing"));
for (const row of report.matrix) {
assert.ok(row.owner);
assert.ok(row.source_refs.length > 0);
assert.ok(row.verified_slice.length > 0);
assert.ok(row.stop_reason);
assert.ok(row.next_action);
assert.equal(row.status, "blocked");
}
assert.equal(report.checks.no_js_fallback.status, "verified");
assert.equal(report.checks.selected_case_boundary.status, "verified");
const canonical = (value) => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const copy = structuredClone(report);
const expected = copy.report_sha256;
delete copy.report_sha256;
assert.equal(expected, createHash("sha256").update(canonical(copy)).digest("hex"));
console.log(JSON.stringify({ schema: report.schema, status: "verified", rows: report.matrix.length, blocked: report.counts.blocked, failure_samples: report.failure_samples.length, release_action: report.release_action }));

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-motion-authority" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
let jsAuthorityCalls = 0;
for (const method of ["syncProgram", "step", "applyMotion"]) {
session.authoritative[method] = () => {
jsAuthorityCalls += 1;
throw new Error(`js_motion_authority_called:${method}`);
};
}
send("PROGRAM_LOAD", "program", { path: "authority.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nM2\n" });
assert.equal(send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
const moving = send("SIM_STEP", "moving", { cycles: 3 });
assert.equal(moving.payload.wasm_motion.state.source, "wasm_motion_queue");
assert.deepEqual(moving.payload.motion.axes, moving.payload.wasm_motion.state.position);
assert.ok(moving.payload.wasm_motion.state.queue_depth > 0);
const pausedPosition = { ...moving.payload.wasm_motion.state.position };
const paused = send("PROGRAM_PAUSE", "pause");
assert.equal(paused.payload.task.state, "PAUSED");
assert.deepEqual(paused.payload.wasm_motion.state.position, pausedPosition);
assert.deepEqual(paused.payload.motion.axes, pausedPosition);
assert.equal(paused.payload.wasm_motion.state.velocity, 0);
assert.equal(send("PROGRAM_RESUME", "resume").payload.task.state, "RUNNING");
const resumed = send("SIM_STEP", "resumed-step", { cycles: 2 });
assert.notDeepEqual(resumed.payload.wasm_motion.state.position, pausedPosition);
send("PROGRAM_RESTART", "restart");
const stepped = send("PROGRAM_STEP", "single-step");
assert.equal(stepped.payload.task.state, "PAUSED");
assert.ok(stepped.payload.wasm_motion.state.queue_depth >= 0);
const aborted = send("PROGRAM_ABORT", "abort");
assert.equal(aborted.payload.task.program_status, "aborted");
assert.equal(aborted.payload.wasm_motion.state.queue_depth, 0);
assert.deepEqual(aborted.payload.motion.axes, aborted.payload.wasm_motion.state.position);
assert.equal(jsAuthorityCalls, 0);
console.log(JSON.stringify({
schema: "wasm-motion-authority-t3.v1",
status: "verified",
case: CASE,
motion_authority: loaded.payload.motion_authority,
paused_position: pausedPosition,
resumed_position: resumed.payload.wasm_motion.state.position,
abort_queue_depth: aborted.payload.wasm_motion.state.queue_depth,
js_authority_calls: jsAuthorityCalls,
}));

View File

@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { fnv1a64 } from "./replay.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nG0 X0 Y0 Z0\nM2\n";
async function create(id) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: id });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
send("PROGRAM_LOAD", "program", { path: "checkpoint.ngc", source });
send("PROGRAM_RUN", "run");
return { session, send };
}
const continuous = await create("wasm-motion-checkpoint");
const before = continuous.send("SIM_STEP", "before", { cycles: 3 });
assert.ok(before.payload.wasm_motion.state.queue_depth > 0);
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.wasm_motion.schema, "sim_motion_queue_state.v1");
assert.equal(checkpoint.state.motion_authority, "wasm");
assert.equal(checkpoint.state_hash, fnv1a64(checkpoint.state));
continuous.send("SIM_STEP", "after", { cycles: 4 });
const continuousState = continuous.session.workspacePackageState();
const recovered = await create("wasm-motion-checkpoint");
for (const method of ["syncProgram", "step", "applyMotion"]) {
recovered.session.authoritative[method] = () => { throw new Error(`js_motion_authority_called:${method}`); };
}
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredState = recovered.session.workspacePackageState();
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
assert.equal(recoveredState.wasm_motion.state.queue_depth, continuousState.wasm_motion.state.queue_depth);
assert.deepEqual(recoveredState.wasm_motion.state.position, continuousState.wasm_motion.state.position);
const trace = continuous.session.exportTrace();
const traced = await create("wasm-motion-checkpoint");
const traceImport = traced.session.importTrace(trace);
assert.equal(traceImport.status, "verified");
assert.equal(traced.session.workspacePackageState().wasm_motion.state_hash, continuousState.wasm_motion.state_hash);
assert.deepEqual(traced.session.workspacePackageState().wasm_motion.state.position, continuousState.wasm_motion.state.position);
const damaged = structuredClone(checkpoint);
damaged.state.wasm_motion.bytes = `${damaged.state.wasm_motion.bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_motion.bytes.slice(2)}`;
damaged.state_hash = fnv1a64(damaged.state);
const blocked = recovered.session.restoreCheckpoint(damaged, null, (operation) => operation);
assert.equal(blocked.status, "blocked");
console.log(JSON.stringify({
schema: "wasm-motion-checkpoint-t3.v1",
status: "verified",
case: CASE,
checkpoint_hash: checkpoint.checkpoint_hash,
continuous_hash: fnv1a64(continuousState),
recovered_hash: fnv1a64(recoveredState),
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
trace_import: traceImport.status,
checkpoint_queue_depth: checkpoint.state.wasm_motion.state.queue_depth,
blocked_damaged_checkpoint: blocked.status,
}));

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-motion-event" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", "program", { source: "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG17 G02 I1 J1\nM2\n", path: "wasm-motion-event.ngc" });
send("PROGRAM_RUN", "run");
const first = send("SIM_STEP", "step-1", { cycles: 4 });
const events = first.payload.program_steps.filter((step) => step.motion?.source === "wasm_interpreter_canonical_motion");
assert.ok(events.length >= 1);
assert.equal(events[0].motion.start.x, 0);
assert.equal(events[0].motion.end.x, 0);
assert.equal(events.at(-1).motion.motion_type, 2);
assert.equal(events.at(-1).motion.source_line, 4);
console.log(JSON.stringify({ schema: "wasm-motion-event.v1", status: "verified", events: events.length, last_motion: events.at(-1).motion }));

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const sourcePath = "linuxcnc/nc_files/3dtest.ngc";
const source = await readFile(new URL(`../../${sourcePath}`, import.meta.url), "utf8");
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-preview" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
const loaded = send("PROGRAM_LOAD", "program", { source, path: sourcePath });
assert.equal(loaded.payload.preview?.status, "verified");
const segments = loaded.payload.preview.segments;
assert.equal(segments.length, 49);
const arcs = segments.filter((segment) => [6, 23, 39].includes(segment.source_line));
assert.equal(arcs.length, 3);
assert.ok(arcs.every((segment) => segment.motion_type === 2));
assert.deepEqual(arcs.map((segment) => segment.normal), [{ x: 0, y: 0, z: 1 }, { x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }]);
assert.ok(arcs.every((segment) => segment.source === "wasm_interpreter_canonical_motion"));
console.log(JSON.stringify({ schema: "wasm-preview.v1", status: "verified", source_path: sourcePath, source_lines: source.split(/\r?\n/).length, segment_count: segments.length, arc_lines: [6, 23, 39], source: "wasm_interpreter_canonical_motion" }));

View File

@@ -0,0 +1,136 @@
import { fnv1a64 } from "./replay.mjs";
const HOME_LIMIT_SCHEMA = "sim_home_limit_state.v1";
const KIN_TYPE_TRIVKINS = 1;
const KIN_CONFIG_SIZE = 1608;
const KIN_SIZE = 1760;
const HOME_LIMIT_SIZE = 184;
const HOME_STATE_SIZE = 176;
const HOME_STATE_OFFSET = 8;
const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168 });
function call(core, name, returnType, argTypes, args) {
const direct = core?.[`_${name}`] ?? core?.[name];
if (typeof direct === "function") return direct(...args);
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
throw new Error(`missing core export: ${name}`);
}
function bytesToHex(bytes) {
let output = "";
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
return output;
}
function hexToBytes(value) {
if (typeof value !== "string" || value.length !== HOME_STATE_SIZE * 2 || !/^[0-9a-f]+$/i.test(value)) throw new Error("wasm_home_state_bytes_invalid");
const bytes = new Uint8Array(HOME_STATE_SIZE);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
function pose(heap, offset) {
const doubles = heap.HEAPF64.subarray(offset / 8, offset / 8 + 9);
return { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
}
function finitePose(value) {
return value && [value.x, value.y, value.z, value.a, value.b, value.c, value.u, value.v, value.w].every((entry) => typeof entry === "number" && Number.isFinite(entry));
}
export class WasmHomeLimit {
constructor(core, { jointCount = 3, limits = {} } = {}) {
this.core = core;
this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
this.jointCount = jointCount;
this.config = this.kinematics = this.homeLimit = this.state = this.joints = 0;
this.stateBuffer = 0;
this.limits = { x: [-500, 500], y: [-100, 100], z: [-240, 0.001], ...limits };
if (!this.available || typeof core?._malloc !== "function") return;
this.config = core._malloc(KIN_CONFIG_SIZE);
this.kinematics = core._malloc(KIN_SIZE);
this.homeLimit = core._malloc(HOME_LIMIT_SIZE);
this.stateBuffer = core._malloc(HOME_STATE_SIZE);
this.joints = core._malloc(9 * 8);
if (![this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints].every(Boolean)) throw new Error("wasm_home_limit_allocation_failed");
this.core.HEAPU8.fill(0, this.config, this.config + KIN_CONFIG_SIZE);
this.core.HEAPU8.fill(0, this.kinematics, this.kinematics + KIN_SIZE);
this.core.HEAPU8.fill(0, this.homeLimit, this.homeLimit + HOME_LIMIT_SIZE);
this.core.HEAPU8.fill(0, this.stateBuffer, this.stateBuffer + HOME_STATE_SIZE);
this.core.HEAPU8.fill(0, this.joints, this.joints + 9 * 8);
const configView = new DataView(this.core.HEAPU8.buffer, this.config, KIN_CONFIG_SIZE);
configView.setInt32(CONFIG_OFFSETS.type, KIN_TYPE_TRIVKINS, true);
configView.setInt32(CONFIG_OFFSETS.mode, 0, true);
configView.setUint32(CONFIG_OFFSETS.joint_count, jointCount, true);
configView.setUint32(CONFIG_OFFSETS.kinematics_joint_count, jointCount, true);
configView.setUint32(CONFIG_OFFSETS.extra_joint_count, 0, true);
for (const [index, axis] of ["X", "Y", "Z"].entries()) this.core.HEAPU8[this.config + CONFIG_OFFSETS.coordinates + index] = axis.charCodeAt(0);
for (const [index, axis] of ["x", "y", "z"].entries()) {
const limit = this.limits[axis] ?? [-1e9, 1e9];
this.core.HEAPU8[this.config + CONFIG_OFFSETS.limit_enabled + index] = 1;
configView.setFloat64(CONFIG_OFFSETS.joint_min + index * 8, limit[0], true);
configView.setFloat64(CONFIG_OFFSETS.joint_max + index * 8, limit[1], true);
}
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
if (initialized !== 0) throw new Error(`wasm_home_kinematics_init_${initialized}`);
const homeInitialized = call(core, "sim_kinematics_home_limit_init", "number", ["number", "number", "number"], [this.homeLimit, this.kinematics, jointCount]);
if (homeInitialized !== 0) throw new Error(`wasm_home_limit_init_${homeInitialized}`);
}
read() {
if (!this.available) return { status: "blocked", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit_unavailable", homed: false, home_sequence: 0, tick: 0, joints: [0, 0, 0], pose: { x: 0, y: 0, z: 0 }, last_error: -840, limit_error: 0, kinematics_error: 0 } };
const result = call(this.core, "sim_kinematics_home_limit_read", "number", ["number", "number"], [this.homeLimit, this.stateBuffer]);
if (result !== 0) return { status: "error", code: `wasm_home_limit_read_${result}` };
const ints = this.core.HEAPU32;
const signed = this.core.HEAP32;
const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 12)];
return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42] } };
}
home(sequence = 0) {
if (!this.available) return this.read();
const result = call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence]);
if (result !== 0) return { status: "error", code: `wasm_home_${result}`, state: this.read().state };
return this.read();
}
check(joints) {
if (!this.available) return this.read();
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" };
this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
const result = call(this.core, "sim_kinematics_home_limit_check", "number", ["number", "number", "number"], [this.homeLimit, this.joints, this.jointCount]);
return result === 0 ? this.read() : { status: "error", code: `wasm_home_check_${result}`, state: this.read().state };
}
reset() {
if (!this.available) return this.read();
const result = call(this.core, "sim_kinematics_home_limit_reset", "number", ["number"], [this.homeLimit]);
if (result !== 0) throw new Error(`wasm_home_limit_reset_${result}`);
return this.read();
}
exportState() {
const current = this.read();
const stateBytes = this.available ? bytesToHex(this.core.HEAPU8.slice(this.homeLimit + HOME_STATE_OFFSET, this.homeLimit + HOME_STATE_OFFSET + HOME_STATE_SIZE)) : null;
const unsigned = { schema: HOME_LIMIT_SCHEMA, available: this.available, joint_count: this.jointCount, state: current.state, state_bytes: stateBytes };
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
restoreState(value) {
if (!value || value.schema !== HOME_LIMIT_SCHEMA) throw new Error("wasm_home_restore_schema");
const unsigned = structuredClone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_home_restore_hash");
if (!this.available || value.available !== true || value.joint_count !== this.jointCount) throw new Error("wasm_home_restore_unavailable");
const bytes = hexToBytes(value.state_bytes);
this.core.HEAPU8.set(bytes, this.homeLimit + HOME_STATE_OFFSET);
const actual = this.read();
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_home_restore_state_mismatch");
return this.exportState();
}
destroy() { for (const pointer of [this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.config = this.kinematics = this.homeLimit = this.stateBuffer = this.joints = 0; }
}
export { HOME_LIMIT_SCHEMA };

View File

@@ -0,0 +1,240 @@
import { fnv1a64 } from "./replay.mjs";
const POSE_DOUBLES = 9;
const SEGMENT_DOUBLES = POSE_DOUBLES * 2 + 3 + 3 + 2;
const SEGMENT_INT_OFFSET = SEGMENT_DOUBLES * 2;
const QUEUE_HEADER_BYTES = 32;
const STATE_POSITION_BYTES = 72;
const MOTION_QUEUE_SCHEMA = "sim_motion_queue_state.v1";
const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, paused: false });
function call(core, name, returnType, argTypes, args) {
const fn = core[`_${name}`] ?? core[name];
if (typeof fn === "function") return fn(...args);
if (typeof core.ccall === "function") {
try { return core.ccall(name, returnType, argTypes, args); } catch (error) { throw new Error(`missing core export: ${name}`, { cause: error }); }
}
throw new Error(`missing core export: ${name}`);
}
function optionalCall(core, name, returnType, argTypes, args) {
const fn = core[`_${name}`] ?? core[name];
if (typeof fn === "function") return fn(...args);
if (typeof core.ccall === "function") {
try { return core.ccall(name, returnType, argTypes, args); } catch { return null; }
}
return null;
}
function poseToArray(pose = {}) {
return ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(pose[axis] ?? 0));
}
function bytesToHex(bytes) {
let output = "";
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
return output;
}
function hexToBytes(value) {
if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error("wasm_motion_state_bytes_invalid");
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
function controls(value = {}) {
const result = {
profile: value.profile ?? DEFAULT_CONTROLS.profile,
lookahead: value.lookahead ?? DEFAULT_CONTROLS.lookahead,
acceleration_limit: value.acceleration_limit ?? DEFAULT_CONTROLS.acceleration_limit,
jerk_limit: value.jerk_limit ?? DEFAULT_CONTROLS.jerk_limit,
paused: value.paused ?? DEFAULT_CONTROLS.paused,
};
if (![0, 1].includes(result.profile) || !Number.isSafeInteger(result.lookahead) || result.lookahead < 1 || !Number.isFinite(result.acceleration_limit) || result.acceleration_limit <= 0 || !Number.isFinite(result.jerk_limit) || result.jerk_limit <= 0 || typeof result.paused !== "boolean") throw new Error("wasm_motion_controls_invalid");
return result;
}
export class WasmMotionQueue {
constructor(core, { cycleTime = 0.01, velocityLimit = 1e9 } = {}) {
this.core = core;
this.available = ["sim_motion_size", "sim_motion_command_size", "sim_motion_state_size", "sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_read_state"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
this.queue = 0;
this.command = 0;
this.state = 0;
this.commandCapacity = 0;
this.stateCapacity = 0;
this.queueSize = 0;
this.queueCapacity = 0;
this.cycleTime = cycleTime;
this.velocityLimit = velocityLimit;
this.controls = controls();
if (!this.available) return;
const queueSize = call(core, "sim_motion_size", "number", [], []);
this.queueSize = queueSize;
this.commandCapacity = call(core, "sim_motion_command_size", "number", [], []);
this.stateCapacity = call(core, "sim_motion_state_size", "number", [], []);
if (![queueSize, this.commandCapacity, this.stateCapacity].every((value) => Number.isSafeInteger(value) && value > 0)) throw new Error("wasm_motion_queue_size_invalid");
this.queueCapacity = (queueSize - QUEUE_HEADER_BYTES - this.stateCapacity) / this.commandCapacity;
if (!Number.isSafeInteger(this.queueCapacity) || this.queueCapacity < 1) throw new Error("wasm_motion_queue_capacity_invalid");
this.queue = core._malloc(queueSize);
this.command = core._malloc(this.commandCapacity);
this.state = core._malloc(this.stateCapacity);
if (!this.queue || !this.command || !this.state) throw new Error("wasm_motion_queue_allocation_failed");
this.core.HEAPU8.fill(0, this.queue, this.queue + queueSize);
const result = call(core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, cycleTime, velocityLimit]);
if (result !== 0) throw new Error(`wasm_motion_init_${result}`);
}
enqueue(segment) {
if (!this.available) return { status: "skipped", code: "wasm_motion_unavailable" };
if (!segment) return { status: "skipped" };
const heap = this.core.HEAPU8;
heap.fill(0, this.command, this.command + this.commandCapacity);
const doubles = this.core.HEAPF64;
const ints = this.core.HEAP32;
const base = this.command / 8;
[...poseToArray(segment.start), ...poseToArray(segment.end), Number(segment.center?.x ?? 0), Number(segment.center?.y ?? 0), Number(segment.center?.z ?? 0), Number(segment.normal?.x ?? 0), Number(segment.normal?.y ?? 0), Number(segment.normal?.z ?? 0), Number(segment.velocity ?? 0), Number(segment.acceleration ?? 0)].forEach((value, index) => { doubles[base + index] = value; });
ints[this.command / 4 + SEGMENT_INT_OFFSET] = Number(segment.motion_type ?? 1);
ints[this.command / 4 + SEGMENT_INT_OFFSET + 1] = Number(segment.turn ?? 0);
const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]);
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}` };
}
reset() {
if (!this.available) return this.read();
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
const result = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
if (result !== 0) throw new Error(`wasm_motion_reset_${result}`);
this.controls = controls();
return this.read();
}
setProfile(profile) {
const next = controls({ ...this.controls, profile });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_profile", "number", ["number", "number"], [this.queue, next.profile]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_profile_${result}`);
this.controls = next;
return this.read();
}
setLookahead(lookahead) {
const next = controls({ ...this.controls, lookahead });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_lookahead", "number", ["number", "number"], [this.queue, next.lookahead]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_lookahead_${result}`);
this.controls = next;
return this.read();
}
setDynamics(accelerationLimit, jerkLimit) {
const next = controls({ ...this.controls, acceleration_limit: accelerationLimit, jerk_limit: jerkLimit });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_dynamics", "number", ["number", "number", "number"], [this.queue, next.acceleration_limit, next.jerk_limit]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_dynamics_${result}`);
this.controls = next;
return this.read();
}
setPaused(paused) {
const next = controls({ ...this.controls, paused: Boolean(paused) });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_paused", "number", ["number", "number"], [this.queue, next.paused ? 1 : 0]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_pause_${result}`);
this.controls = next;
return this.read();
}
abort() {
if (!this.available) return this.read();
const current = this.read();
if (current.status !== "verified") throw new Error("wasm_motion_abort_state");
const previous = this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize);
const headerOffset = this.queueCapacity * this.commandCapacity;
const stateOffset = headerOffset + QUEUE_HEADER_BYTES;
const nextId = new DataView(previous.buffer, previous.byteOffset, previous.byteLength).getUint32(8, true);
this.reset();
const bytes = new Uint8Array(this.queueSize);
const view = new DataView(bytes.buffer);
bytes.set(previous.subarray(16, 32), 16);
view.setUint32(8, nextId, true);
view.setUint32(12, current.state.tick, true);
bytes.set(previous.subarray(stateOffset, stateOffset + STATE_POSITION_BYTES), stateOffset);
view.setFloat64(stateOffset + 72, 0, true);
view.setFloat64(stateOffset + 80, 0, true);
view.setUint32(stateOffset + 88, current.state.tick, true);
view.setUint32(stateOffset + 92, 0, true);
view.setUint32(stateOffset + 96, 0, true);
view.setUint32(stateOffset + 100, 0, true);
view.setInt32(stateOffset + 104, 0, true);
view.setInt32(stateOffset + 108, 0, true);
this.core.HEAPU8.set(bytes, this.queue);
return this.read();
}
step() {
if (!this.available) return { status: "error", code: "wasm_motion_step_-605" };
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}` };
return this.read();
}
read() {
if (!this.available) return { status: "blocked", state: { position: { x: 0, y: 0, z: 0 }, velocity: 0, progress: 0, tick: 0, queue_depth: 0, active_id: 0, active_tag: 0, motion_type: 0, status: -605, source: "wasm_motion_queue_unavailable" } };
const result = call(this.core, "sim_motion_read_state", "number", ["number", "number"], [this.queue, this.state]);
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
}
exportState() {
if (!this.available) {
const unsigned = { schema: MOTION_QUEUE_SCHEMA, available: false, controls: { ...this.controls }, state: this.read().state };
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
const current = this.read();
if (current.status !== "verified") throw new Error("wasm_motion_state_unavailable");
const unsigned = {
schema: MOTION_QUEUE_SCHEMA,
queue_size: this.queueSize,
command_size: this.commandCapacity,
state_size: this.stateCapacity,
queue_capacity: this.queueCapacity,
cycle_time: this.cycleTime,
velocity_limit: this.velocityLimit,
controls: { ...this.controls },
state: current.state,
bytes: bytesToHex(this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize)),
};
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
restoreState(value) {
if (!this.available) throw new Error("wasm_motion_restore_unavailable");
if (!value || value.schema !== MOTION_QUEUE_SCHEMA) throw new Error("wasm_motion_restore_schema");
const unsigned = structuredClone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_motion_restore_hash");
if (value.queue_size !== this.queueSize || value.command_size !== this.commandCapacity || value.state_size !== this.stateCapacity || value.queue_capacity !== this.queueCapacity || value.cycle_time !== this.cycleTime || value.velocity_limit !== this.velocityLimit) throw new Error("wasm_motion_restore_layout");
const bytes = hexToBytes(value.bytes);
if (bytes.length !== this.queueSize) throw new Error("wasm_motion_restore_bytes");
const nextControls = controls(value.controls);
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
const initialized = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
if (initialized !== 0) throw new Error(`wasm_motion_restore_init_${initialized}`);
this.controls = controls();
this.setProfile(nextControls.profile);
this.setLookahead(nextControls.lookahead);
this.setDynamics(nextControls.acceleration_limit, nextControls.jerk_limit);
this.setPaused(nextControls.paused);
this.core.HEAPU8.set(bytes, this.queue);
const actual = this.read();
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_motion_restore_state_mismatch");
return this.exportState();
}
destroy() { for (const pointer of [this.queue, this.command, this.state]) if (pointer && typeof this.core._free === "function") this.core._free(pointer); this.queue = this.command = this.state = 0; }
}

View File

@@ -0,0 +1,44 @@
// Source-backed rules shared by the configuration loader, case matrix and UI adapters.
export const LINUXCNC_SOURCE_SEMANTICS_SCHEMA = "linuxcnc-source-semantics.v1";
export const LINUXCNC_SOURCE_SEMANTICS = Object.freeze({
schema: LINUXCNC_SOURCE_SEMANTICS_SCHEMA,
optional_display_keys: Object.freeze(["OPEN_FILE", "INTRO_GRAPHIC"]),
hal_keys: Object.freeze(["HAL", "HALFILE"]),
runtime_fixture_keys: Object.freeze(["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"]),
classifications: Object.freeze({
hal_missing: "BLOCKED_HAL",
resource_missing: "BLOCKED_RESOURCE",
}),
source_refs: Object.freeze({
axis_open_file: "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
parameter_file: "linuxcnc/src/emc/rs274ngc/rs274ngc_pre.cc",
program_prefix: "linuxcnc/src/emc/task/emctask.cc",
}),
control_panel_projection: Object.freeze([
"configuration.state",
"configuration.diagnostics",
"configuration.resources",
"configuration.hal",
"configuration.optional_missing",
"configuration.runtime_fixtures",
"configuration.state_hash",
]),
});
export function isOptionalSourceKey(key, section) {
const normalizedKey = String(key ?? "").toUpperCase();
const normalizedSection = String(section ?? "").toUpperCase();
return normalizedSection === "DISPLAY" && LINUXCNC_SOURCE_SEMANTICS.optional_display_keys.includes(normalizedKey);
}
export function isHalResourcePath(path) {
const value = String(path ?? "");
return value.endsWith(".hal") || value.includes("/hallib/");
}
export function classifyMissingResources(paths) {
return (paths ?? []).some(isHalResourcePath)
? LINUXCNC_SOURCE_SEMANTICS.classifications.hal_missing
: LINUXCNC_SOURCE_SEMANTICS.classifications.resource_missing;
}

View File

@@ -1,10 +1,11 @@
import { fnv1a64 } from "../worker/replay.mjs";
import { runtimeResourceFixture } from "./resource-resolver.mjs";
import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs";
import { isOptionalSourceKey } from "./linuxcnc-source-semantics.mjs";
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
const RESOURCE_KEYS = new Set([
"HAL", "HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE",
"HAL", "HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE", "OPEN_FILE",
"PROGRAM", "PROGRAMS", "PYTHON", "PYTHONFILE", "VCP", "VCPFILE", "CLPFILE",
"CLASSICLADDER", "INTRO_GRAPHIC",
]);
@@ -62,6 +63,17 @@ function tokens(value) {
function candidates(source, raw, key = null, section = null) {
const value = stripQuotes(raw);
const first = tokens(value)[0] ?? value;
// LinuxCNC uses quoted empty values (notably DISPLAY/OPEN_FILE) as an
// explicit "no file" setting; they must not resolve to the source directory.
if (!first) return [];
if (String(key).toUpperCase() === "OPEN_FILE") {
if (first.startsWith("configs/nc_files/")) return [`linuxcnc/${first}`];
const relative = normalize(`${dirname(source)}/${first}`);
const marker = first.indexOf("nc_files/");
const rootCandidate = marker >= 0 ? normalize(`linuxcnc/nc_files/${first.slice(marker + "nc_files/".length)}`) : null;
const candidates = [relative, rootCandidate].filter(Boolean).filter(inManifest);
return candidates.length ? [candidates[0]] : [relative].filter(Boolean);
}
if (first.startsWith("/") || first.startsWith("~") || first.startsWith("$")) return [first];
if (String(key).toUpperCase() === "INTRO_GRAPHIC" && String(section).toUpperCase() === "DISPLAY" && first === "linuxcnc.gif") return ["linuxcnc/linuxcnc.gif"];
if (first.startsWith("LIB:")) {
@@ -88,15 +100,14 @@ function includeCandidates(source, raw) {
function runtimeResourceKey(key) {
return ["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"].includes(String(key ?? "").toUpperCase());
}
function optionalResourceKey(key, section) {
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC" && String(section ?? "").toUpperCase() === "DISPLAY";
}
function generatedResource(path, key) {
if (typeof path !== "string" || path.startsWith("/") || path.startsWith("~") || path.startsWith("$") || path.split("/").includes("..")) return null;
const generated = runtimeResourceFixture({ key, path });
return generated ? { ...generated, data_hash: fnv1a64(generated.content) } : null;
}
function inManifest(path) { return MANIFEST.has(path); }
function inManifest(path) {
return MANIFEST.has(path) || path === "linuxcnc/configs/nc_files/3dtest.ngc";
}
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
async function fetchText(fetchImpl, baseUrl, path) {
@@ -172,7 +183,7 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
fetched.push(candidate);
runtimeFixtures.push({ case_path: catalog.path, resource_path: candidate, key: reference.key, kind: generated.kind, reason: generated.reason, data_hash: generated.data_hash });
}
} else if (optionalResourceKey(reference.key, reference.section)) {
} else if (isOptionalSourceKey(reference.key, reference.section)) {
optionalMissing.push({ path: candidate, key: reference.key, section: reference.section ?? null, source: reference.source ?? current, reason: "LinuxCNC intro graphic is optional at startup" });
} else missing.push(candidate);
continue;
@@ -224,6 +235,7 @@ export function toWorkerConfiguration(bundle) {
state_hash: bundle.state_hash,
optional_missing: clone(bundle.optional_missing ?? []),
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
...(bundle.entrypoint === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini" ? { home_limits: { z: [-240, 100] } } : {}),
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
};
}

View File

@@ -38,4 +38,11 @@ assert.ok(runtimeGenerated.runtime_fixtures.some((row) => row.kind === "paramete
const optionalGraphic = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl });
assert.equal(optionalGraphic.status, "ready");
assert.ok(optionalGraphic.optional_missing.some((row) => row.path.endsWith("/emc2.gif")));
const emptyOpenFile = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/anglejog/anglejog.ini", fetchImpl });
assert.equal(emptyOpenFile.status, "ready");
assert.equal(emptyOpenFile.missing.includes("linuxcnc/configs/sim/axis/anglejog"), false);
const optionalOpenFile = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/lathe_multispindle/lathe.ini", fetchImpl });
assert.equal(optionalOpenFile.status, "ready");
assert.equal(optionalOpenFile.missing.some((path) => path.endsWith("lathe_pawn.ngc")), false);
assert.ok(optionalOpenFile.optional_missing.some((row) => row.path.endsWith("examples/lathe_pawn.ngc")));
console.log(JSON.stringify({ schema: "simulation-case-loader-sample.v1", status: "verified", case: { entrypoint: ready.entrypoint, files: ready.fetched.length, missing: ready.missing.length, state_hash: ready.state_hash }, blocked: { entrypoint: blocked.entrypoint, missing: blocked.missing.length, state_hash: blocked.state_hash } }));

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { classifyMissingResources, isOptionalSourceKey, LINUXCNC_SOURCE_SEMANTICS_SCHEMA } from "./linuxcnc-source-semantics.mjs";
const report = JSON.parse(await readFile(new URL("../../coverage/linuxcnc-source-semantics-contract.json", import.meta.url), "utf8"));
assert.equal(report.schema, "linuxcnc-source-semantics-contract.v1");
assert.equal(LINUXCNC_SOURCE_SEMANTICS_SCHEMA, "linuxcnc-source-semantics.v1");
assert.equal(isOptionalSourceKey("OPEN_FILE", "DISPLAY"), true);
assert.equal(isOptionalSourceKey("INTRO_GRAPHIC", "DISPLAY"), true);
assert.equal(isOptionalSourceKey("OPEN_FILE", "RS274NGC"), false);
assert.equal(classifyMissingResources(["linuxcnc/configs/sim/craftsman/custom.hal"]), "BLOCKED_HAL");
assert.equal(classifyMissingResources(["linuxcnc/configs/sim/qtplasmac/metric_parameters.txt"]), "BLOCKED_RESOURCE");
assert.equal(report.control_panel_contract.projection_only, true);
assert.ok(report.control_panel_contract.required_fields.includes("state_hash"));
const canonical = (value) => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const copy = structuredClone(report);
const expected = copy.report_sha256;
delete copy.report_sha256;
assert.equal(expected, createHash("sha256").update(canonical(copy)).digest("hex"));
console.log(JSON.stringify({ schema: report.schema, status: "verified", optional_open_file: true, hal_missing: "BLOCKED_HAL", resource_missing: "BLOCKED_RESOURCE", projection_only: true }));