Align AXIS control flow and fix pause and program rendering

Document source UI gates, explicit native live-plot clearing, and remaining parity boundaries. Fix pause/resume transport continuity, repeated stepping, coordinate previews, and spindle override state. Optimize large program snapshots and rendering, and cover the control flows with native Tcl, WASM, and browser checks.

Validation: 22 native tests and source/browser checks pass. Worker/App regression: 113 of 123 pass; 10 pre-existing failures remain documented with no new failures.
This commit is contained in:
2026-09-08 10:46:27 +08:00
parent 845075f4a3
commit bade560372
55 changed files with 2495 additions and 286 deletions

View File

@@ -22,8 +22,9 @@ export class AxisOperationController {
try {
const response = await this.client.send(kind, payload, operationId);
const responsePayload = response?.payload && typeof response.payload === "object" ? response.payload : {};
const workerBlocked = response?.kind === "ERROR" || responsePayload.status === "blocked";
const workerBlocked = response?.kind === "ERROR" || ["blocked", "error"].includes(responsePayload.status);
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: workerBlocked ? "blocked" : "result", reason: responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? response?.kind ?? null }));
if (workerBlocked) throw Object.assign(new Error(responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? "axis_worker_rejected"), { response, operationId });
return Object.freeze({ operationId, response, gate: effectiveGate });
} catch (error) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));

View File

@@ -14,4 +14,14 @@ assert.equal(jog.operationId, "axis-jog-start-000002");
const tick = await controller.execute("SIM_STEP", { cycles: 1 }, { state: ready, gate: false });
assert.equal(tick.operationId, "axis-sim-step-000003");
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "pending", "result", "pending", "result"]);
for (const response of [
{ kind: "ERROR", payload: { gate_reason: "xyzac_machine_off" } },
{ kind: "COMMAND_RESULT", payload: { status: "blocked", code: "abi_missing" } },
{ kind: "COMMAND_RESULT", payload: { status: "error", message_key: "interpreter_error" } },
]) {
const rejected = new AxisOperationController({ send: async () => response });
await assert.rejects(rejected.execute("PROGRAM_RUN"), (error) => error.response === response);
assert.equal(rejected.exportTrace().some((entry) => entry.phase === "result"), false);
assert.equal(rejected.exportTrace()[1].phase, "blocked");
}
console.log(JSON.stringify({ schema: "axis-operation-controller-sample.v1", status: "verified", calls: calls.length, phases: controller.exportTrace().map((entry) => entry.phase), last_operation: tick.operationId }));

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
type Props = {
controlId: string;
label: string;
children: ReactNode;
disabled: boolean;
disabledReason?: string;
active: boolean;
group: RefObject<boolean>;
onPress: () => Promise<void> | void;
onRelease?: () => Promise<void> | void;
};
// A release owns the callbacks captured at press time, even across rerenders.
export function HoldButton({ controlId, label, children, disabled, disabledReason, active, group, onPress, onRelease }: Props) {
const held = useRef<{ pointer: number | null; key: string | null; finish: () => void } | null>(null);
const release = () => {
const gesture = held.current;
if (!gesture) return;
held.current = null;
gesture.finish();
};
const press = (pointer: number | null, key: string | null) => {
if (disabled || !active || held.current || group.current) return;
group.current = true;
const pending = Promise.resolve().then(onPress);
void pending.catch(() => undefined);
held.current = { pointer, key, finish: () => {
void pending.then(() => onRelease?.()).catch(() => undefined).finally(() => { group.current = false; });
} };
};
useEffect(() => {
const visibility = () => { if (document.hidden) release(); };
const pointerUp = (event: PointerEvent) => { if (held.current?.pointer === event.pointerId) release(); };
const keyUp = (event: KeyboardEvent) => { if (held.current?.key === event.key) release(); };
window.addEventListener("blur", release);
window.addEventListener("pointerup", pointerUp);
window.addEventListener("pointercancel", pointerUp);
window.addEventListener("keyup", keyUp);
document.addEventListener("visibilitychange", visibility);
return () => {
release();
window.removeEventListener("blur", release);
window.removeEventListener("pointerup", pointerUp);
window.removeEventListener("pointercancel", pointerUp);
window.removeEventListener("keyup", keyUp);
document.removeEventListener("visibilitychange", visibility);
};
}, []);
useEffect(() => { if (!active || disabled) release(); }, [active, disabled]);
return <button type="button" className="axis-text-button" data-axis-control={controlId}
disabled={disabled} title={disabled ? disabledReason : label} aria-label={label}
onPointerDown={(event) => {
if (event.button !== 0) return;
press(event.pointerId, null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) return;
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* Synthetic events have no active pointer. */ }
}}
onPointerMove={(event) => {
if (!held.current || held.current.pointer !== event.pointerId) return;
const bounds = event.currentTarget.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) release();
}}
onPointerUp={release} onPointerCancel={release} onPointerLeave={release} onLostPointerCapture={release}
onKeyDown={(event) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
if (!event.repeat) press(null, event.key);
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
release();
}}
onBlur={release}>{children}</button>;
}

View File

@@ -27,12 +27,12 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050a0e);
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera = new THREE.PerspectiveCamera(40, 1, 0.001, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
element.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
let controls: OrbitControls<THREE.PerspectiveCamera | THREE.OrthographicCamera> = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = .12;
controls.enableZoom = true;
@@ -60,12 +60,18 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const toolGroup = new THREE.Group(); toolGroup.name = "xyzac-preview-tool"; scene.add(toolGroup);
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-preview-overlays"; scene.add(overlayGroup);
const offsetAxes = new THREE.AxesHelper(1.5); offsetAxes.name = "xyzac-preview-offset-axes"; overlayGroup.add(offsetAxes);
const limitGrid = new THREE.GridHelper(2.5, 10, 0x8b4d47, 0x4f2d2d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
const limitGrid = new THREE.Box3Helper(new THREE.Box3(), 0xd8756d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
const extents = new THREE.Box3Helper(new THREE.Box3(), 0xf2cf65); extents.name = "xyzac-preview-extents"; overlayGroup.add(extents);
const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: .45, metalness: .35 });
const tool = new THREE.Mesh(new THREE.CylinderGeometry(.04, .04, .58, 20), toolMaterial); tool.name = "xyzac-preview-tool-shaft"; toolGroup.add(tool);
const tipGeometry = new THREE.ConeGeometry(.09, .28, 24); tipGeometry.rotateZ(Math.PI); tipGeometry.translate(0, .14, 0);
const tip = new THREE.Mesh(tipGeometry, new THREE.MeshBasicMaterial({ color: 0xf2f2f2 })); tip.name = "xyzac-preview-tool-tip"; toolGroup.add(tip);
const resize = () => { const width = Math.max(element.clientWidth, 280); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
const resize = () => {
const width = Math.max(element.clientWidth, 280), height = Math.max(element.clientHeight, 260);
if (camera instanceof THREE.PerspectiveCamera) camera.aspect = width / height;
else { camera.left = -camera.top * width / height; camera.right = -camera.left; }
camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize); observer.observe(element); resize();
const fit = (segments: Segment[]) => {
const points = segments.flatMap((segment) => (Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples : [segment.start ?? {}, segment.end ?? {}]).map(sourcePoint));
@@ -90,6 +96,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let liveTick = -1;
let liveTrace: THREE.Vector3[] = [];
let pathSegments: Segment[] | null = null;
let extentsSegments: Segment[] | null = null;
// Native TP active_id is the source line/motion id and may repeat (for
// example, each call of the same helix subroutine). Preserve every
// occurrence instead of letting a Map overwrite all but one geometry.
@@ -109,6 +116,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let paintedActiveSegment = 0;
let paintedProgress = 0;
let liveHistoryLine: THREE.Line | null = null;
let historyVertexCount = 0;
let activeLiveLine: THREE.Line | null = null;
let livePathEpoch = -1;
let lastHistoryTick = -1;
@@ -116,14 +124,25 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let lastPreset = "";
const presetCamera = (selected: Props["view"], selectedZoom: number) => {
const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom);
controls.dispose();
const aspect = Math.max(element.clientWidth, 280) / Math.max(element.clientHeight, 260);
const span = bounds.radius * 1.3 / Math.max(.5, selectedZoom);
camera = selected === "p" ? new THREE.PerspectiveCamera(40, aspect, .001, 100)
: new THREE.OrthographicCamera(-span * aspect, span * aspect, span, -span, .001, 100);
camera.up.set(0, 1, 0);
if (selected === "z") camera.up.set(0, 0, 1);
if (selected === "z2") camera.up.set(-1, 0, 0);
if (selected === "y2") camera.up.set(1, 0, 0);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = .12; controls.screenSpacePanning = true;
controls.target.copy(bounds.center);
if (selected === "z") camera.position.copy(bounds.center).add(new THREE.Vector3(0, distance, .01));
else if (selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .3, distance * .9, distance * .3));
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(distance, .02, .01));
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, distance));
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, -distance));
if (selected === "z" || selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, -distance, 0));
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(-distance, 0, 0));
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, distance));
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, -distance));
else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance));
camera.lookAt(bounds.center);
element.dataset.xyzacProjection = selected === "p" ? "perspective" : "orthographic";
controls.update();
};
const pan = (horizontal: number, vertical: number) => {
@@ -246,22 +265,14 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
// vertices so preview loading does not block the UI on one WebGL
// object and geometry allocation per G-code line.
const batched = { rapid: [] as THREE.Vector3[], completed: [] as THREE.Vector3[] };
const sampledLines: Array<{ points: THREE.Vector3[]; rapid: boolean }> = [];
for (const segment of segments) {
const start = segment.start ?? {}; const end = segment.end ?? {};
const sampled = Array.isArray(segment.samples) && segment.samples.length > 1;
const points = sampled ? segment.samples!.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
const rapidPath = segment.kind === "rapid";
if (sampled) sampledLines.push({ points, rapid: rapidPath });
else (rapidPath ? batched.rapid : batched.completed).push(...points);
const target = rapidPath ? batched.rapid : batched.completed;
for (let index = 1; index < points.length; index++) target.push(points[index - 1], points[index]);
}
const addLine = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
if (points.length < 2) return;
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
if (dashed) line.computeLineDistances();
line.renderOrder = 10;
pathGroup.add(line);
};
const addSegments = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
if (points.length < 2) return;
const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material);
@@ -271,7 +282,8 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
};
addSegments(batched.rapid, rapid, true);
addSegments(batched.completed, completed);
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
element.dataset.xyzacPreviewDrawCalls = String(pathGroup.children.length);
element.dataset.xyzacPreviewVertices = String(batched.rapid.length + batched.completed.length);
}
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
@@ -291,10 +303,30 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
element.dataset.xyzacRenderedTick = String(renderedTick);
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
if (historyDue) {
if (liveHistoryLine) { liveHistoryLine.geometry.dispose(); liveGroup.remove(liveHistoryLine); }
liveHistoryLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(liveTrace), liveHistory);
liveHistoryLine.renderOrder = 19;
liveGroup.add(liveHistoryLine);
if (!liveHistoryLine) {
liveHistoryLine = new THREE.Line(new THREE.BufferGeometry(), liveHistory);
liveHistoryLine.renderOrder = 19;
liveHistoryLine.frustumCulled = false;
liveGroup.add(liveHistoryLine);
historyVertexCount = 0;
}
let attribute = liveHistoryLine.geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!attribute || attribute.count < liveTrace.length) {
const capacity = Math.max(1024, 2 ** Math.ceil(Math.log2(liveTrace.length)));
attribute = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3).setUsage(THREE.DynamicDrawUsage);
liveHistoryLine.geometry.dispose();
liveHistoryLine.geometry = new THREE.BufferGeometry();
liveHistoryLine.geometry.setAttribute("position", attribute);
historyVertexCount = 0;
}
for (let index = historyVertexCount; index < liveTrace.length; index++) {
const point = liveTrace[index];
attribute.setXYZ(index, point.x, point.y, point.z);
}
attribute.addUpdateRange(historyVertexCount * 3, (liveTrace.length - historyVertexCount) * 3);
attribute.needsUpdate = true;
historyVertexCount = liveTrace.length;
liveHistoryLine.geometry.setDrawRange(0, historyVertexCount);
lastHistoryTick = renderedTick;
}
const nextActiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}`;
@@ -326,17 +358,32 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
}
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4));
originGroup.scale.setScalar(Math.max(.35, bounds.radius * .55));
originGroup.scale.setScalar(Math.max(.015, bounds.radius * .35));
const pose = sourcePoint(renderedPose);
toolGroup.position.copy(pose);
toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0);
const toolScale = Math.min(1, Math.max(0.15, bounds.radius * 0.55));
const toolScale = Math.min(1, Math.max(0.015, bounds.radius * 0.35));
toolGroup.scale.setScalar(toolScale);
tool.position.set(0, .57, 0); tip.position.set(0, 0, 0);
grid.visible = true; limitGrid.visible = layer.showLimits; offsetAxes.visible = layer.showOffsets;
offsetAxes.position.copy(sourcePoint({ x: state.offsets.x_rot_point, y: state.offsets.y_offset, z: state.offsets.z_offset }));
if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}\nHelix ${segments.filter((segment) => segment.kind === "helix").length} / Samples ${segments.reduce((count, segment) => count + (Array.isArray(segment.samples) ? segment.samples.length : 0), 0)}\nLive ${renderedActiveSegment || "-"} / ${renderedProgress.toFixed(3)}\nTick ${renderedTick}\nHash ${state.stateHash.slice(0, 18)}`;
controls.update(); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
grid.visible = true;
const limits = state.machineLimits;
limitGrid.visible = layer.showLimits && Boolean(limits);
if (limits?.length === 3) limitGrid.box.setFromPoints([sourcePoint({ x: limits[0][0], y: limits[1][0], z: limits[2][0] }), sourcePoint({ x: limits[0][1], y: limits[1][1], z: limits[2][1] })]);
offsetAxes.visible = layer.showOffsets && Boolean(state.coordinateOffset);
const offset = state.coordinateOffset ?? [0, 0, 0];
offsetAxes.position.copy(sourcePoint({ x: offset[0], y: offset[1], z: offset[2] }));
extents.visible = layer.showExtents && segments.length > 0;
if (extents.visible && extentsSegments !== segments) {
extentsSegments = segments;
extents.box.setFromPoints(segments.flatMap((segment) => (segment.samples?.length ? segment.samples : [segment.start, segment.end]).filter(Boolean).map((point) => sourcePoint(point ?? {}))));
}
element.dataset.xyzacLimitsVisible = String(limitGrid.visible);
element.dataset.xyzacExtentsVisible = String(extents.visible);
element.dataset.xyzacOffset = JSON.stringify(offset);
if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}`;
if (layer.showVelocity && hudRef.current) hudRef.current.textContent += `\nV ${Number(state.velocityMmMin ?? 0).toFixed(1)} mm/min`;
controls.mouseButtons.LEFT = rotateRef.current ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN;
controls.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render);
};
render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); clearLines(pathGroup); clearLines(liveGroup); completed.dispose(); rapid.dispose(); live.dispose(); liveHistory.dispose(); originGroup.traverse((child) => { const mesh = child as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material as (THREE.Material & { map?: THREE.Texture }) | undefined; material?.map?.dispose?.(); material?.dispose?.(); }); controls.dispose(); delete (element as typeof element & { xyzacController?: unknown }).xyzacController; renderer.dispose(); element.replaceChildren(); };
@@ -346,7 +393,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length;
const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : <button type="button" data-axis-control={`preview-${action}`} data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
}
export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {

View File

@@ -18,6 +18,7 @@ export type MessageEnvelope<TPayload = unknown> = {
};
export type MotionSnapshot = {
stepping?: boolean;
axes?: number[];
joints?: number[];
velocity?: number[];
@@ -78,6 +79,8 @@ export type SimSnapshotV1 = {
kinematics?: XyzacKinematicsSnapshot;
xyzac?: XyzacCapabilitySnapshot;
gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
ui_gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
ui_relief?: Record<string, boolean>;
motion_authority?: string;
task_authority?: string;
};
@@ -129,6 +132,8 @@ export type PanelSnapshotV1 = {
configuration: Record<string, unknown> | null;
axis_state: AxisStateView;
gates: Record<string, { action: string; enabled: boolean; reason: string }>;
ui_gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
ui_relief?: Record<string, boolean>;
visual: {
schema: "axis-visual.v1";
pose: Record<string, number>;

View File

@@ -161,6 +161,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
xyzac: panel.xyzac,
io: panel.io ?? previous.snapshot?.io,
gates: panel.gates,
ui_gates: panel.ui_gates,
ui_relief: panel.ui_relief,
motion_authority: panel.motion_authority,
task_authority: panel.task_authority,
} satisfies SimSnapshotV1;

View File

@@ -31,7 +31,7 @@ button { color: inherit; }
.connection-state.ready, .connection-state.running { color: #185c1d; }
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
.axis-menubar { display: flex; align-items: stretch; min-width: 0; min-height: 27px; padding-left: 3px; overflow-x: auto; border-bottom: 1px solid #888; background: #d3d3d3; }
.axis-menubar { display: flex; align-items: stretch; min-width: 0; min-height: 27px; padding-left: 3px; position: relative; z-index: 30; overflow: visible; border-bottom: 1px solid #888; background: #d3d3d3; }
.axis-menu-item { position: relative; }
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
.axis-menu-item summary::-webkit-details-marker { display: none; }
@@ -50,10 +50,10 @@ button { color: inherit; }
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
.axis-case-strip { display: flex; align-items: center; width: 100%; min-width: 0; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
.axis-case-strip { display: flex; flex-wrap: wrap; align-items: center; width: 100%; min-width: 0; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
.axis-case-strip label { display: inline-flex; align-items: center; min-width: 0; flex: 0 0 auto; gap: 5px; font-size: 12px; }
.axis-case-strip select { width: min(420px, 46vw); max-width: 100%; min-height: 24px; border: 1px solid var(--border-dark); background: white; }
.axis-case-strip > .axis-text-button { min-width: 68px; white-space: nowrap; }
.axis-case-strip > .axis-text-button { flex: 0 0 auto; min-width: max-content; white-space: nowrap; }
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.case-status { min-width: 0; flex: 1 1 auto; }
.case-status.loading { color: #805400; font-weight: 700; }
@@ -153,6 +153,7 @@ button { color: inherit; }
.axis-about-dialog p { margin: 8px 0 16px; }
.device-status { color: #555; font: 11px ui-monospace, monospace; }
.manual-coolant-block { grid-column: 1; }
.manual-coolant-block .check-line { margin-right: 10px; }
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
.manual-sliders { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 6px 16px; margin-top: 13px; padding-top: 9px; border-top: 1px solid #b0b0b0; }
@@ -191,7 +192,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.axis-metrics { padding: 4px 9px; border-top: 1px solid #999; background: #cecece; }
.axis-slider { display: grid; grid-template-columns: repeat(3, minmax(170px, 1fr)); gap: 12px; }
.axis-slider label { grid-template-columns: auto 48px; }
.axis-slider label { grid-template-columns: minmax(0, 1fr) max-content; }
@media (max-width: 900px) { .xyzac-parameter-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); } }
@media (max-width: 560px) { .xyzac-parameter-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
@@ -258,8 +259,8 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
.axis-notebook { min-height: 330px; }
.axis-preview { min-height: 320px; }
.axis-program-pane { flex: 0 0 auto; height: auto; min-height: 700px; grid-template-columns: 1fr; grid-template-rows: minmax(520px, 1fr) auto; }
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(260px, 1fr); grid-column: 1; grid-row: 1; }
.axis-program-pane { flex: 0 0 auto; height: 740px; min-height: 740px; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 140px; }
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); grid-column: 1; grid-row: 1; overflow: hidden; }
.program-editor-wrap { grid-column: 1; grid-row: 1; }
.program-execution-panel { grid-column: 1; grid-row: 2; border-top: 1px solid #9aa8b4; border-left: 0; }
.program-side { grid-column: 1; grid-row: 2; border-top: 1px solid #aaa; border-left: 0; }

View File

@@ -12,6 +12,9 @@ export type XyzacVisualState = {
livePathEpoch: number;
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
offsets: XyzacKinematicsSnapshot["offsets"];
coordinateOffset?: number[];
machineLimits?: number[][];
velocityMmMin?: number;
activeSegmentId: number | null;
toolpath: Array<Record<string, unknown>>;
source: "linuxcnc-wasm-worker";
@@ -24,6 +27,7 @@ export type XyzacVisualSource = {
simulation_tick?: number;
state_hash?: string;
kinematics?: XyzacKinematicsSnapshot;
motion?: { velocity_mm_min?: number };
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
};
@@ -52,6 +56,9 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
motionTrace,
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
coordinateOffset: (snapshot?.visual as { coordinate_offset?: number[] })?.coordinate_offset,
machineLimits: (snapshot?.visual as { machine_limits?: number[][] })?.machine_limits,
velocityMmMin: (snapshot?.visual as { velocity_mm_min?: number })?.velocity_mm_min ?? snapshot?.motion?.velocity_mm_min,
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
toolpath: snapshot?.visual?.toolpath ?? [],
source: "linuxcnc-wasm-worker",

View File

@@ -0,0 +1,22 @@
// UI predicates from axis.tcl::update_state, independent of task acceptance.
export function axisSourceUi({ task_state, interp_state, taskfile = false, queued_mdi_commands = 0, max_queued_mdi_commands = 10, motion_mode = "FREE", kinematics_type = "BOTH", tool = 0, spindle_direction = 0, on_any_limit = false, paused = false } = {}) {
const on = task_state === "STATE_ON";
const idle = interp_state === "INTERP_IDLE";
const manual = on && idle;
const touch = manual && (motion_mode !== "FREE" || kinematics_type === "IDENTITY");
const predicates = {
estop: true, power: task_state !== "STATE_ESTOP",
load: idle, reload: idle && Boolean(taskfile), run: manual, step: on && Boolean(taskfile),
pause: on && ["INTERP_READING", "INTERP_WAITING"].includes(interp_state),
resume: on && interp_state === "INTERP_PAUSED", pauseResume: on && !idle,
abort: on && !idle, manual, home: manual, jog: manual, spindle: manual, coolant: manual,
mdi: on && queued_mdi_commands < max_queued_mdi_commands,
touchOff: touch, toolTouchOff: touch && tool > 0,
spindleAdjust: manual && spindle_direction !== 0, overrideLimits: Boolean(on_any_limit),
override: true, jogScale: true, maxVelocity: true, spindleOverride: true,
};
return {
gates: Object.fromEntries(Object.entries(predicates).map(([action, enabled]) => [action, { action, enabled, reason: enabled ? "axis_source_ui_available" : `axis_source_ui_${action}_disabled` }])),
relief: { estop: task_state === "STATE_ESTOP", power: on, run: !idle, pauseResume: Boolean(paused), abort: idle },
};
}

View File

@@ -37,14 +37,13 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {},
const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
const ioEstop = hasIoState && Boolean(ioState.estop);
const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null;
// A powered-off machine is OFF. ESTOP_RESET is distinct and is only
// reported after the WASM IO latch has recorded the ESTOP edge.
const estopReset = hasIoState && !ioEstop && ioReady === false && Number(ioState.last_error) === -904;
let machineState = ioEstop || ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : estopReset ? "ESTOP_RESET" : ioReady === false ? "OFF" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
// axis.tcl displays STATE_ESTOP_RESET as OFF; diagnostics are not state bits.
const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF"
: taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
let operationState = programActivity(task, program);
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE";
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "OFF" ? "STATE_ESTOP_RESET" : "STATE_ON";
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? (program?.program_status === "completed" || String(task.mode).toUpperCase() === "MDI" ? "INTERP_WAITING" : "INTERP_READING") : "INTERP_IDLE";
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });
}

View File

@@ -21,6 +21,7 @@ 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 { axisSourceUi } from "./axis-source-ui.mjs";
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
@@ -301,6 +302,7 @@ export class SimSession {
on_any_limit: Boolean(home.state?.limit_error),
following_error: 0,
jog: null,
stepping: this.wasmMotion.isStepping?.() ?? false,
estop: Boolean(ioState?.estop),
fault: ioState?.fault ? (ioState.fault_reason ?? "io_fault") : null,
};
@@ -369,6 +371,11 @@ export class SimSession {
return { status: "verified" };
}
clearLivePlot() {
this.livePathEpoch += 1;
return this.livePathEpoch;
}
panelSnapshot({ task = this.task.export(), program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick, motionTrace = null, includeToolpath = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeConfiguration = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeStableRuntime = input.kind !== "SIM_STEP" || !this.xyzacProgram } = {}) {
const motion = this.xyzacProgram ? this.wasmMotionProjection(program) : this.motion.export();
const wasmMotion = this.wasmMotion.read();
@@ -384,9 +391,15 @@ export class SimSession {
const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state,
taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0,
motion_mode: homeLimit.state?.homed ? "TELEOP" : "FREE", kinematics_type: "BOTH",
spindle_direction: Number(devices.spindles?.[0]?.direction ?? 0), tool: Number(devices.tool?.current ?? 0),
on_any_limit: motion.on_any_limit, paused: axis.operation_state === "PAUSED" });
const gates = this.xyzacProgram
? {
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
reload: this.xyzacProgram.path ? this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }) : { enabled: false, reason: "axis_taskfile_missing" },
run: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
runLine: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
step: this.xyzacCommandGate({ kind: "PROGRAM_STEP", payload: {} }),
@@ -442,8 +455,14 @@ export class SimSession {
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
...(Array.isArray(motionTrace) && motionTrace.length ? { motion_trace: motionTrace } : {}),
source: "wasm_canonical_motion",
velocity_mm_min: motion.velocity_mm_min ?? 0,
machine_limits: this.wasmMotion.axisLimits?.() ?? null,
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
};
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
const coordinateContext = program?.coordinate_context;
const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0);
const displayedOffsets = [0, 1, 2, 3, 5].map((index) => activeOffsets[index]);
const unsigned = {
schema: "axis-panel-snapshot.v1",
context_id: this.contextId,
@@ -452,13 +471,14 @@ export class SimSession {
task,
program,
motion,
coordinates: { machine: [...axisCoordinates], relative: [...axisCoordinates], commanded: [...axisCoordinates] },
coordinates: { machine: [...axisCoordinates], relative: axisCoordinates.map((value, index) => value - displayedOffsets[index]), commanded: [...axisCoordinates], offsets: displayedOffsets, system: coordinateContext?.system ?? 1 },
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
wasm_motion: wasmMotion,
...(includeStableRuntime ? { home_limit: homeLimit, hal: { configuration: this.halState, runtime: halRuntime }, devices, device_io: deviceIo } : {}),
...(includeConfiguration ? { configuration: this.configurationState } : {}),
axis_state: axis,
gates,
...(this.xyzacProgram ? { ui_gates: { ...ui.gates, toolTouchOff: gates.toolTouchOff }, ui_relief: ui.relief } : {}),
visual,
authoritative,
motion_authority: this.motionAuthority,
@@ -536,8 +556,8 @@ export class SimSession {
const loaded = program.status !== 0;
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG
: input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" ? WASM_GATE_COMMANDS.JOG
: input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
@@ -607,15 +627,16 @@ export class SimSession {
const value = Number(payload.value);
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
const systemMatch = selectedSystem.match(/^(P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/);
const systemMatch = selectedSystem.match(/^(P[0-9]|G(?:5[4-8]|59(?:\.[123])?))(?:\s+.*)?$/);
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
const systemToken = systemMatch[1];
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
const displayUnits = String(payload.units ?? "").toLowerCase();
const programUnits = Number(this.xyzacProgram.snapshot().units);
const converted = displayUnits === "mm" && programUnits === 20 ? value / 25.4
: displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
const linear = ["X", "Y", "Z"].includes(axis);
const converted = linear && displayUnits === "mm" && programUnits === 20 ? value / 25.4
: linear && displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
const formatted = Number(converted.toFixed(12));
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
@@ -626,14 +647,19 @@ export class SimSession {
try {
if (savedMode !== "MDI") this.task.setMode("MDI");
this.wasmMotion.setPaused(false);
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
if (synchronized.status !== "verified") return synchronized;
const offset = this.xyzacProgram.executeBlock(block);
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
const preview = this.xyzacProgram.preview();
this.previewState = preview.status === "verified" ? preview : null;
if (preview.status !== "verified") return preview;
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
if (savedMode !== "MDI") this.task.setMode(savedMode);
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
} catch (error) {
try { if (savedMode !== "MDI" && this.task.state.mode !== savedMode) this.task.setMode(savedMode); } catch { /* preserve the original interpreter error */ }
return { status: "error", code: String(error?.message ?? error) };
} finally {
if (this.task.state.mode !== savedMode) this.task.setMode(savedMode);
}
}
@@ -1317,6 +1343,10 @@ export class SimSession {
};
const deviceResponse = (kind, payload) => {
if (this.xyzacProgram && ["SPINDLE_COMMAND", "SPINDLE_BRAKE", "SPINDLE_CONSTANT"].includes(kind) && !payload?.source_keyboard && (kind !== "SPINDLE_COMMAND" || payload?.delta === undefined)) {
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); }
catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
}
const result = this.xyzacIo ? this.xyzacIo.command(kind, payload ?? {}) : this.devices.command(kind, payload ?? {});
if (result.status === "verified" && this.xyzacProgram && kind === "ESTOP" && payload?.active === true) {
// LinuxCNC emcTaskSetState(ESTOP) calls emcTaskAbort: clear the
@@ -1390,7 +1420,7 @@ export class SimSession {
const visualClearResponse = () => {
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
this.livePathEpoch += 1;
this.clearLivePlot();
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
};
@@ -1637,7 +1667,7 @@ export class SimSession {
// worker. LinuxCNC keeps the paused task/TP state stable; accept that
// transport frame as a no-op instead of rejecting it at the protocol
// layer and leaving the next PROGRAM_RESUME with a sequence gap.
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3)) {
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3) && !this.wasmMotion.isStepping()) {
const pausedProgram = this.xyzacProgram.snapshot();
return finish(this.envelope("COMMAND_RESULT", input, {
status: "verified",
@@ -1661,7 +1691,7 @@ export class SimSession {
task: (nextTick) => {
if (this.xyzacProgram) {
const wasmState = this.xyzacProgram.snapshot();
if (![2, 3].includes(wasmState.status)) return;
if (wasmState.status !== 2) return;
if (this.pendingTaskSync !== 0) {
this.pendingTaskSync = 0;
return;
@@ -1854,7 +1884,9 @@ export class SimSession {
// cycle, so advance the WASM motion queue by the configured
// servo/task ratio while publishing one stable task snapshot.
let wasmMotion = this.wasmMotion.read();
const programPaused = this.xyzacProgram?.snapshot().status === 3;
for (let servo = 0; servo < this.xyzacTaskCycleTicks; servo += 1) {
if (programPaused && !this.wasmMotion.isStepping()) break;
if (this.planner === "linuxcnc-tp" && typeof this.wasmMotion.setExecutionState === "function") {
this.wasmMotion.setExecutionState(this.xyzacExecutionWaitSamples > 0 ? 2 : 3);
}
@@ -1971,6 +2003,7 @@ export class SimSession {
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
}
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
if (program) snapshotState.program = program;
@@ -2047,7 +2080,8 @@ export class SimSession {
}
restoreMode();
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
}), finalStatus);
}
restoreMode();
const result = this.program.load(source, input.payload?.path ?? null);
@@ -2130,7 +2164,7 @@ export class SimSession {
// false ERR_STATE. The LinuxCNC WASM TP remains authoritative for
// the actual pause/resume motion transition.
if (action === "pause") { this.wasmMotion.setPaused(true); this.task.transition("PAUSED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("paused", program.execution_source_line ?? program.current_line); }
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram(program.status === 4 ? "completed" : program.status === 1 ? "loaded" : "running", program.execution_source_line ?? program.current_line); }
else { this.wasmMotion.abort(); this.task.transition("STOPPED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("aborted", program.execution_source_line ?? program.current_line); }
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
@@ -2204,25 +2238,47 @@ export class SimSession {
}
case "PROGRAM_STEP": {
if (this.xyzacProgram) {
const current = this.xyzacProgram.snapshot();
if (current.status === 1 && this.task.state.mode !== "AUTO") this.task.setMode("AUTO");
if (current.status === 1) this.xyzacProgram.start();
const result = this.xyzacProgram.step();
if (result.status === "verified") {
// AXIS AUTO_STEP returns to an interpreter-paused state after the
// single canonical block. Keep the WASM interpreter status and
// task status aligned so the Resume gate is authoritative.
if (result.program_status !== 4) {
const pauseResult = this.xyzacProgram.pause();
if (pauseResult.status !== "verified") return finish(this.error(input, "program_pause", pauseResult.code ?? "program_pause_failed"), "failed");
try {
const current = this.xyzacProgram.snapshot();
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state);
this.task.setMode("AUTO");
if (idle) {
const started = this.xyzacProgram.start();
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
} else if (current.status === 3 && this.wasmMotion.queueDepth() > 0) {
this.wasmMotion.stepLine();
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: current, task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
} else {
if (current.status === 3) this.xyzacProgram.resume();
const stepped = this.xyzacProgram.step();
if (stepped.status !== "verified") return finish(this.error(input, "program_step", stepped.code), "failed");
const sync = this.syncXyzacProgramSpindle(stepped.program);
if (sync.status !== "verified" && sync.status !== "skipped") return finish(this.error(input, "program_spindle", sync.code), "failed");
if (stepped.has_control && [428, 429, 430].includes(stepped.control_code)) {
const selected = this.switchXyzacKinematics({ 428: 1, 429: 0, 430: 2 }[stepped.control_code]);
if (selected.status !== "verified") return finish(this.error(input, "program_step", selected.code), "failed");
}
if (stepped.motion) {
stepped.motion.kinematics_mode = this.xyzacProgramKinematicsMode;
const queued = this.wasmMotion.enqueue(stepped.motion);
if (queued.status === "error") return finish(this.error(input, "motion_queue", queued.code), "failed");
}
}
if (this.task.state.state === "READY") this.task.transition("RUNNING");
if (result.motion) this.wasmMotion.enqueue(result.motion);
if (this.xyzacProgram.snapshot().status === 4 && this.wasmMotion.read().state.queue_depth === 0) {
this.task.setProgram("completed", this.xyzacProgram.snapshot().current_line);
if (this.task.state.state !== "STOPPED") this.task.transition("STOPPED");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
}
const paused = this.xyzacProgram.pause();
if (paused.status !== "verified") return finish(this.error(input, "program_pause", paused.code), "failed");
this.wasmMotion.setPaused(true);
this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line);
this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED");
if (["READY", "STOPPED"].includes(this.task.state.state)) this.task.transition("RUNNING");
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
this.task.setProgram("paused", this.xyzacProgram.snapshot().current_line);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
} catch (error) {
return finish(this.error(input, "program_step", String(error?.message ?? error)), "failed");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
if (this.task.state.state === "READY") {
this.task.setMode("AUTO");
@@ -2280,6 +2336,8 @@ export class SimSession {
// WASM TP, so clear that transport pause before queuing MDI motion;
// the queue itself remains authoritative for execution.
this.wasmMotion.setPaused(false);
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
if (synchronized.status !== "verified") return finish(this.error(input, "mdi_synch", synchronized.code), "failed");
const result = this.xyzacProgram.executeBlock(block);
if (result.status === "verified") {
const controlCode = Number(result.program?.control_code ?? 0);
@@ -2295,6 +2353,7 @@ export class SimSession {
if (result.motion) {
const enqueue = this.wasmMotion.enqueue(result.motion);
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
if (this.wasmMotion.read().state.queue_depth > 0 && this.task.state.state !== "RUNNING") this.task.transition("RUNNING");
}
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
@@ -2334,11 +2393,13 @@ export class SimSession {
if (this.xyzacProgram) {
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
if (requestedAxis && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) return finish(this.error(input, "home_limit", "home_joint_requires_identity"), "failed");
if (requestedAxis) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed");
const requestedJoint = input.payload?.joint;
if (requestedJoint !== undefined && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed");
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
// before issuing the homing command.
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
const home = this.wasmHomeLimit.home(input.seq, requestedAxis);
const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : requestedJoint ?? null);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
@@ -2391,9 +2452,10 @@ export class SimSession {
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
this.wasmMotion.setPaused(false);
const requestedIncrement = input.payload?.increment;
const distance = requestedIncrement === null || input.payload?.continuous === true
? Math.max(Number(this.wasmMotion.jogVelocity(axis)) * this.xyzacMotionCycleTime * 5, Number.EPSILON)
: requestedIncrement ?? 1;
const continuous = requestedIncrement === null || input.payload?.continuous === true;
const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1;
const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale;
if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed");
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
try { const source = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => source }; }
catch { return { ok: false, text: async () => "" }; }
} });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new XyzacSession(core, { sessionId: "axis-source-execution" });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-exec-${seq}`, kind, payload });
const ok = (kind, payload = {}) => {
const response = send(kind, payload);
assert.notEqual(response.kind, "ERROR", `${kind}: ${response.payload?.gate_reason ?? response.payload?.message_key ?? response.payload?.code}`);
return response.payload;
};
const panel = () => ok("SNAPSHOT_REQUEST").panel_snapshot;
const drain = () => { for (let n = 0; n < 2000 && session.wasmMotion.queueDepth() > 0; n++) ok("SIM_STEP", { cycles: 1, response: "motion_delta" }); };
ok("HELLO"); ok("SESSION_ATTACH"); ok("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 F600\nG1 X2\nM2\n", path: "source-step.ngc" });
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
ok("SET_OVERRIDE", { feed: 0.9 });
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
ok("POWER", { active: true });
assert.equal(panel().ui_gates.run.enabled, true);
assert.equal(panel().ui_gates.step.enabled, true);
assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed");
ok("HOME", { all: true });
assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode");
ok("PROGRAM_STEP");
assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses");
ok("PROGRAM_STEP");
assert.equal(panel().program.current_line, 1);
ok("PROGRAM_STEP");
assert.equal(panel().program.current_line, 2);
assert.ok(session.wasmMotion.queueDepth() > 0);
ok("PROGRAM_STEP");
assert.equal(panel().motion.stepping, true);
drain();
assert.ok(Math.abs(panel().visual.pose.x - 1) < 1e-8);
assert.equal(panel().program.current_line, 2);
assert.equal(panel().axis_state.operation_state, "PAUSED");
ok("PROGRAM_STEP"); ok("PROGRAM_STEP"); drain();
assert.ok(Math.abs(panel().visual.pose.x - 2) < 1e-8);
ok("PROGRAM_ABORT");
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X5 F600\nG1 X10\nM2\n", path: "source-waiting.ngc" });
ok("PROGRAM_RUN");
for (let n = 0; n < 20 && panel().program.program_status !== "completed"; n++) ok("SIM_STEP", { cycles: 1 });
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
ok("PROGRAM_PAUSE");
const paused = panel().visual.pose;
ok("SIM_STEP", { cycles: 2 });
assert.deepEqual(panel().visual.pose, paused);
ok("PROGRAM_RESUME");
assert.equal(panel().program.program_status, "completed", "Resume restores WAITING, not READING");
drain(); ok("SIM_STEP", { cycles: 1 });
assert.ok(Math.abs(panel().visual.pose.x - 10) < 1e-8);
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
ok("PROGRAM_LOAD", { source: "G21 G90\nM2\n", path: "source-mdi.ngc" });
ok("MDI_EXECUTE", { block: "G1 X12 F600" });
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
assert.equal(panel().ui_gates.mdi.enabled, true);
assert.equal(panel().ui_gates.jog.enabled, false);
assert.equal(send("MDI_EXECUTE", { block: "G0 X0" }).kind, "ERROR");
ok("PROGRAM_PAUSE"); ok("PROGRAM_RESUME"); drain(); ok("SIM_STEP", { cycles: 1 });
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
assert.ok(Math.abs(panel().visual.pose.x - 12) < 1e-8);
ok("SPINDLE_COMMAND", { rpm: -600, enabled: true });
ok("SET_SPINDLE_OVERRIDE", { scale: 0 });
assert.equal(panel().devices.spindles[0].direction, -1);
assert.equal(panel().devices.spindles[0].command_rpm, 0);
ok("SET_SPINDLE_OVERRIDE", { scale: 1 });
assert.equal(panel().devices.spindles[0].command_rpm, -600);
ok("SPINDLE_COMMAND", { rpm: 0, enabled: false });
console.log(JSON.stringify({ schema: "axis-source-execution.v1", status: "verified", checks: ["ui_vs_command_gate", "source_off_state", "switchkins_type", "repeated_step", "step_motion", "waiting_pause_resume", "mdi_motion_state", "spindle_zero_override"] }));

View File

@@ -13,8 +13,13 @@ assert.equal(axisState({ task: { state: "READY" }, program: { program_status: "i
assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING");
assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED");
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: 0 } } }).machine_state, "OFF");
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: -904 } } }).machine_state, "ESTOP_RESET");
for (const last_error of [0, -904, -903]) {
const state = axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error } } });
assert.equal(state.machine_state, "OFF");
assert.equal(state.source_task_state, "STATE_ESTOP_RESET");
}
assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted");
assert.equal(axisState({ task: { state: "FAULT" }, io: { state: { estop: false, ready: true } } }).source_task_state, "STATE_ON");
assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted");
assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted");
assert.equal(validateAxisTransition("RUNNING", "HOME").status, "blocked");

View File

@@ -41,10 +41,14 @@ assert.equal(readyGates.tool.enabled, true);
assert.equal(readyGates.probe.enabled, true);
assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1);
const alternateKinematicsGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates;
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_mode_unavailable");
// Switchkins leaves MDI selected; AXIS spindle() ensures MANUAL itself.
assert.equal(alternateKinematicsGates.spindle.enabled, true);
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_authority");
assert.equal(alternateKinematicsGates.tool.reason, "linuxcnc_wasm_mode_unavailable");
assert.equal(send("RESET").payload.command, "RESET");
assert.equal(send("MDI_EXECUTE", { block: "G0 X1" }).payload.task.mode, "MDI");
assert.equal(send("JOG_STOP").kind, "ERROR", "jog stop must not abort a running MDI move");
assert.equal(send("SIM_STEP", { cycles: 100 }).kind, "COMMAND_RESULT");
assert.equal(send("JOG_STOP").payload.command, "JOG_STOP");
assert.equal(send("SET_MODE", { mode: "MANUAL" }).payload.mode, "MANUAL");
assert.equal(send("POWER", { active: false }).payload.panel_snapshot.axis_state.machine_state, "OFF");
@@ -72,7 +76,7 @@ assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
assert.equal(send("ESTOP", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ESTOP");
const reset = send("ESTOP", { active: false });
assert.equal(reset.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(reset.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
console.log(JSON.stringify({

View File

@@ -56,7 +56,7 @@ const estopGates = xyzacGateMatrix({ task: { state: "ESTOP", mode: "MANUAL" }, p
assert.equal(estopGates.switchkinsXyzac.enabled, false);
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
send("ESTOP", "estop-on", { active: true });
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
send("POWER", "power-on-again", { active: true });
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");

View File

@@ -50,7 +50,7 @@ assert.equal(released.payload.status, "verified");
assert.equal(released.payload.device_io.state.estop, 0);
assert.equal(released.payload.device_io.state.ready, 0);
assert.equal(released.payload.task.state, "STOPPED");
assert.equal(released.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(released.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
const powered = send("POWER", { active: true });
assert.equal(powered.payload.status, "verified");
@@ -97,9 +97,16 @@ const restarted = send("PROGRAM_RESTART");
assert.equal(restarted.payload.status, "verified");
const fullRun = send("PROGRAM_RUN");
assert.equal(fullRun.payload.task.state, "RUNNING");
const completed = send("SIM_STEP", { cycles: 8000 });
let completed = send("SIM_STEP", { cycles: 8000 });
assert.equal(completed.payload.status, "verified");
assert.equal(completed.payload.program.program_status, "completed");
// M2 closes the interpreter before the last trajectory segment drains.
let drainTicks = 0;
for (; drainTicks < 100 && completed.payload.wasm_motion.state.queue_depth > 0; drainTicks += 1) {
assert.equal(completed.payload.panel_snapshot.gates.run.enabled, false);
completed = send("SIM_STEP", { cycles: 1 });
assert.equal(completed.payload.status, "verified");
}
assert.equal(completed.payload.task.state, "STOPPED");
assert.equal(completed.payload.wasm_motion.state.queue_depth, 0);
assert.equal((completed.payload.panel_snapshot.visual.toolpath ?? loaded.payload.panel_snapshot.visual.toolpath).length, 29);
@@ -120,7 +127,7 @@ console.log(JSON.stringify({
active_progress: stepped.payload.panel_snapshot.visual.active_progress,
rtcp_active_type: stepped.payload.panel_snapshot.kinematics.active_type,
rtcp_joint_pose_delta: stepped.payload.panel_snapshot.kinematics.joints.map((value, index) => value - [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c][index]),
full_run: { cycles: 8000, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
full_run: { cycles: 8000 + drainTicks, drain_ticks: drainTicks, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
js_motion_unchanged: session.motion.export().state_hash === jsMotionHash,
state_hash: stepped.payload.panel_snapshot.state_hash,
}));

View File

@@ -23,7 +23,7 @@ function setup(id, { homeAll = true } = {}) {
const partialHome = setup("xyzac-manual-partial-home", { homeAll: false });
for (const [index, axis] of ["x", "y", "z", "a", "c"].entries()) {
const response = partialHome.send("HOME", `home-${axis}`, { axis });
const response = partialHome.send("HOME", `home-${axis}`, { joint: index });
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (index + 1)) - 1);
assert.equal(response.payload.wasm_home_limit.state.homed, index === 4);
}
@@ -60,15 +60,22 @@ assert.equal(ferror.kind, "ERROR");
assert.equal(ferror.payload.code, "motion_following_error");
const mdiCase = setup("xyzac-manual-mdi");
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z180 A30 C45 F100" });
// The selected INI limits Z to [-120, 120]; use an in-range completion case.
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z18 A30 C45 F600" });
assert.equal(mdi.kind, "COMMAND_RESULT");
assert.equal(mdi.payload.motion_authority, "wasm");
assert.equal(mdi.payload.program.source, "linuxcnc-wasm-sim_program");
assert.equal(mdi.payload.panel_snapshot.kinematics.active_type, 0);
const invalidMdi = mdiCase.send("MDI_EXECUTE", "mdi-invalid", { block: "G999" });
assert.equal(invalidMdi.kind, "ERROR");
assert.equal(invalidMdi.payload.gate_reason, "mdi_while_program_active");
for (let tick = 0; tick < 20000 && mdiCase.session.wasmMotion.read().state.queue_depth > 0; tick += 32) {
const sample = mdiCase.send("SIM_STEP", `mdi-drain-${tick}`, { cycles: 32, response: "motion_delta" });
assert.equal(sample.kind, "COMMAND_RESULT", sample.payload.message_key);
}
assert.equal(mdiCase.session.wasmMotion.read().state.queue_depth, 0);
const loaded = mdiCase.send("PROGRAM_LOAD", "mdi-program", { source: "G21 G90\nG1 X20 A10 C20 F100\nM2\n", path: "mdi-run.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify({ reason: loaded.payload.gate_reason ?? loaded.payload.message_key, code: loaded.payload.code, preview: loaded.payload.preview, program: loaded.payload.program?.program_status }));
mdiCase.send("PROGRAM_RUN", "mdi-run");
const mdiWhileRunning = mdiCase.send("MDI_EXECUTE", "mdi-running", { block: "G0 X1" });
assert.equal(mdiWhileRunning.kind, "ERROR");

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => body }; }
catch { return { ok: false, text: async () => "" }; }
} });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new XyzacSession(core, { sessionId: "pause-consistency" });
let seq = 0;
const send = (kind, payload = {}, accepted = true) => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, payload, seq: ++seq, operation_id: `pause-consistency-${seq}` });
assert.equal(result.kind === "ERROR", !accepted, `${kind}: ${result.payload.gate_reason ?? result.payload.message_key ?? result.payload.code}`);
assert.equal(core._sim_protocol_last_seq(), seq, `${kind} stranded the transport`);
assert.equal(session.timeline.events.cursor, seq, `${kind} stranded the event log`);
if (!accepted) assert.equal(result.payload.code, "axis_gate");
return result.payload;
};
const motion = () => session.wasmMotion.read().state;
const snapshot = () => session.xyzacProgram.snapshot();
const sample = (cycles = 5) => send("SIM_STEP", { cycles, response: "motion_delta" });
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
send("PROGRAM_LOAD", { path: "pause-reading.ngc", source: "G21 G90\n" + Array.from({ length: 80 }, (_, i) => `G1 X${i % 2 ? 0 : 2} F600`).join("\n") + "\nM2\n" });
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN"); sample();
for (let round = 0; round < 3; round++) {
send("PROGRAM_PAUSE");
const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth;
const epoch = session.livePathEpoch;
sample(); assert.deepEqual(motion().position, pose);
send("VISUAL_CLEAR"); assert.equal(session.livePathEpoch, epoch + 1);
for (const [kind, payload] of [["SET_SPINDLE_OVERRIDE", { scale: 1 }], ["SET_MAX_VELOCITY", { percent: 100 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_OVERRIDE", { feed: 1 }]]) send(kind, payload);
for (const [kind, payload] of [["PROGRAM_PAUSE", {}], ["PROGRAM_RUN", {}], ["PROGRAM_LOAD", { path: "bad.ngc", source: "M2\n" }], ["MDI_EXECUTE", { block: "G0 X100" }], ["HOME", { all: true }], ["JOG_START", { axis: "x", direction: 1, increment: 1 }]]) send(kind, payload, false);
assert.equal(snapshot().current_line, line);
assert.equal(snapshot().program_status, "paused");
assert.deepEqual(motion().position, pose);
assert.equal(motion().queue_depth, queue);
send("PROGRAM_RESUME");
for (let n = 0; n < 3; n++) sample();
assert.notDeepEqual(motion().position, pose, "Resume did not advance the preserved TP");
send("PROGRAM_RESUME", {}, false);
sample();
}
send("PROGRAM_PAUSE");
const beforeStep = motion().position;
send("PROGRAM_STEP");
for (let tick = 0; tick < 300 && session.wasmMotion.isStepping(); tick++) sample(1);
assert.notDeepEqual(motion().position, beforeStep, "Step while paused did not move");
assert.equal(snapshot().program_status, "paused");
send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample(); send("PROGRAM_ABORT");
assert.equal(motion().queue_depth, 0);
send("PROGRAM_LOAD", { path: "pause-waiting.ngc", source: "G21 G90\nG1 X30 F600\nM2\n" });
send("PROGRAM_RUN");
for (let tick = 0; tick < 30 && snapshot().status !== 4; tick++) sample(1);
assert.equal(snapshot().status, 4); assert.ok(motion().queue_depth > 0);
send("PROGRAM_PAUSE"); send("VISUAL_CLEAR"); send("PROGRAM_RESUME");
assert.equal(snapshot().status, 4, "WAITING resume must not rewind or re-read");
for (let tick = 0; tick < 1000 && motion().queue_depth; tick++) sample();
assert.ok(Math.abs(motion().position.x - 30) < 1e-8);
send("PROGRAM_LOAD", { path: "pause-mdi.ngc", source: "G21 G90\nM2\n" });
send("MDI_EXECUTE", { block: "G1 X20 F600" }); sample();
send("PROGRAM_PAUSE");
const mdiPose = motion().position;
send("SET_SPINDLE_OVERRIDE", { scale: .8 }); send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample();
assert.notDeepEqual(motion().position, mdiPose);
assert.equal(session.task.state.mode, "MDI");
send("PROGRAM_ABORT");
console.log(JSON.stringify({ schema: "xyzac-pause-consistency.v1", status: "verified", commands: seq, transport_seq: core._sim_protocol_last_seq(), checks: ["reading_repeated_pause", "clear_and_overrides_while_paused", "rejected_commands_keep_sequence", "paused_step_then_resume", "waiting_resume_to_completion", "mdi_pause_resume", "abort_after_resume"] }));

View File

@@ -3,6 +3,7 @@ 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";
import { fnv1a64 } from "./replay.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
@@ -29,6 +30,38 @@ assert.equal(second.payload.program.path, "second.ngc");
assert.notEqual(second.payload.program.source_hash, first.payload.program.source_hash);
assert.equal(second.payload.panel_snapshot.visual.toolpath.length, 1);
const epoch = () => session.livePathEpoch;
const before = epoch();
const source = "G21 G90\nG1 X2 Y2 Z3 F100\nM2\n";
const replaced = send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true });
assert.equal(replaced.kind, "COMMAND_RESULT");
assert.equal(replaced.payload.visual_clear, undefined);
assert.equal(epoch(), before, "changed file retains the native trace, even from an old caller");
assert.equal(replaced.payload.program.source_hash, fnv1a64(source));
assert.equal(session.xyzacProgram.snapshot().source_hash, fnv1a64(source));
assert.equal(send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true }).kind, "COMMAND_RESULT");
assert.equal(epoch(), before, "unchanged reload preserves live history");
const failedReplacement = send("PROGRAM_LOAD", { source: "G999\n", path: "bad-web.ngc", clear_live_plot_on_replace: true });
assert.equal(failedReplacement.kind, "ERROR");
assert.equal(epoch(), before, "failed parse does not clear history");
const native = send("PROGRAM_LOAD", { source, path: "native.ngc" });
assert.equal(native.kind, "COMMAND_RESULT");
assert.equal(epoch(), before, "default source PROGRAM_LOAD preserves history");
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN");
const blocked = send("PROGRAM_LOAD", { source, path: "blocked.ngc", clear_live_plot_on_replace: true });
assert.equal(blocked.kind, "ERROR");
assert.equal(epoch(), before, "gate rejection does not clear history");
send("PROGRAM_ABORT");
const edited = send("PROGRAM_LOAD", { source: source.replace("X2", "X3"), path: "native.ngc", clear_live_plot_on_replace: true });
assert.equal(edited.kind, "COMMAND_RESULT");
assert.equal(epoch(), before, "edited text retains the native live trace");
assert.equal(edited.payload.program.source_hash, fnv1a64(source.replace("X2", "X3")));
const cleared = send("VISUAL_CLEAR");
assert.equal(cleared.kind, "COMMAND_RESULT");
assert.equal(epoch(), before + 1);
assert.deepEqual(cleared.payload.before, cleared.payload.after, "clear must not change CNC state");
assert.deepEqual(cleared.payload.panel_snapshot.visual.toolpath, edited.payload.panel_snapshot.visual.toolpath);
console.log(JSON.stringify({
schema: "xyzac-program-replacement.v1",
status: "verified",

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const bundle = await loadSimulationCase({
path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
fetchImpl: async (url) => {
try { const text = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => text }; }
catch { return { ok: false, text: async () => "" }; }
},
});
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new XyzacSession(core, { sessionId: "source-widget-gates" });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-widget-${seq}`, kind, payload });
const snapshot = () => send("SNAPSHOT_REQUEST").payload.panel_snapshot;
send("HELLO"); send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).kind, "COMMAND_RESULT");
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG1 X100 F60\nM2\n", path: "source-widget.ngc" }).kind, "COMMAND_RESULT");
for (const estop of [false, true]) {
send("ESTOP", { active: estop });
for (const [kind, payload] of [["SET_OVERRIDE", { feed: 0.8 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_MAX_VELOCITY", { percent: 60 }], ["SET_SPINDLE_OVERRIDE", { scale: 0.7 }]]) {
const response = send(kind, payload);
assert.equal(response.kind, "COMMAND_RESULT", `${kind} in ${estop ? "ESTOP" : "OFF"}: ${response.payload.gate_reason ?? response.payload.message_key}`);
}
assert.equal(snapshot().io.state.spindles[0].enabled, 0);
assert.equal(snapshot().io.state.spindles[0].command_rpm, 0);
assert.equal(snapshot().ui_gates.abort.enabled, false, "Stop widget must be disabled at IDLE");
}
send("ESTOP", { active: false }); send("POWER", { active: true }); send("HOME", { all: true });
send("SET_MODE", { mode: "MDI" });
assert.equal(snapshot().gates.spindle.enabled, true);
assert.equal(snapshot().gates.touchOff.enabled, true);
assert.equal(send("SPINDLE_COMMAND", { direction: 1, rpm: 100, enabled: true }).kind, "COMMAND_RESULT");
assert.equal(snapshot().task.mode, "MANUAL", "source spindle radio handler ensures MANUAL");
send("SPINDLE_COMMAND", { direction: 0, rpm: 0, enabled: false });
send("PROGRAM_RUN"); send("SIM_STEP", { cycles: 2 });
assert.equal(snapshot().ui_gates.abort.enabled, true);
assert.equal(snapshot().ui_gates.coolant.enabled, false, "source manual checkbox group requires IDLE");
assert.equal(snapshot().gates.load.enabled, false);
send("PROGRAM_PAUSE");
assert.equal(snapshot().gates.load.enabled, false);
assert.equal(snapshot().ui_gates.abort.enabled, true);
send("PROGRAM_ABORT");
assert.equal(snapshot().ui_gates.abort.enabled, false);
assert.equal(snapshot().ui_gates.coolant.enabled, true);
console.log(JSON.stringify({ schema: "xyzac-source-widget-gates.v1", status: "verified", cases: ["OFF", "ESTOP", "MDI_IDLE", "AUTO_RUNNING", "PAUSED", "ABORTED_IDLE"] }));

View File

@@ -35,11 +35,38 @@ for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cyc
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
const beforeTouch = session.wasmMotion.read().state.position;
const epochBefore = send("SNAPSHOT_REQUEST").payload.panel_snapshot.visual.live_path_epoch;
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
assert.equal(systemTouch.kind, "COMMAND_RESULT");
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.task.mode, "MANUAL");
let panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
assert.deepEqual(session.wasmMotion.read().state.position, beforeTouch);
assert.ok(Math.abs(panel.coordinates.relative[0] - 12) < 1e-9);
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - (beforeTouch.x - 12 + 10)) < 1e-9);
assert.equal(panel.visual.live_path_epoch, epochBefore);
for (const axis of ["A", "C"]) {
const angular = send("TOUCH_OFF", { axis, value: 15, system: "P1 G54", units: "inch" });
assert.equal(angular.kind, "COMMAND_RESULT", JSON.stringify(angular.payload));
assert.equal(angular.payload.block, `G10 L20 P1 ${axis}15`);
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
assert.equal(panel.coordinates.relative[axis === "A" ? 3 : 4], 15);
}
const savedOffsets = panel.program.coordinate_context;
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X13 A15 C15\nM2\n", path: "touch-reload.ngc" }).kind, "COMMAND_RESULT");
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
assert.deepEqual(panel.program.coordinate_context, savedOffsets);
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - beforeTouch.x - 1) < 1e-9);
assert.equal(send("TOUCH_OFF", { axis: "X", value: 0, system: "G54.1" }).payload.code, "touch_off_system_invalid");
assert.equal(send("TOUCH_OFF", { axis: "X", value: 20, system: "G59.3" }).kind, "COMMAND_RESULT");
assert.equal(send("MDI_EXECUTE", { block: "G59.3" }).kind, "COMMAND_RESULT");
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
assert.equal(panel.coordinates.system, 9);
assert.ok(Math.abs(panel.coordinates.relative[0] - 20) < 1e-9);
assert.equal(send("MDI_EXECUTE", { block: "G54" }).kind, "COMMAND_RESULT");
assert.equal(send("SET_MODE", { mode: "MANUAL" }).kind, "COMMAND_RESULT");
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");

View File

@@ -56,7 +56,7 @@ const estop = send("ESTOP", "io-estop", { active: true });
assert.equal(estop.payload.device_io.source, "wasm_io");
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
assert.equal(blocked.kind, "ERROR");
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
assert.equal(send("POWER", "power-on-after-estop", { active: true }).payload.device_io.source, "wasm_io");
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;

View File

@@ -98,7 +98,8 @@ export class WasmHomeLimit {
home(sequence = 0, axis = null) {
if (!this.available) return this.read();
const index = axis === null || axis === undefined ? -1 : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
const index = axis === null || axis === undefined ? -1 : Number.isInteger(axis) ? axis : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
if (index >= this.jointCount) return { status: "error", code: "wasm_home_joint_invalid", state: this.read().state };
if (axis !== null && index < 0) return { status: "error", code: "wasm_home_axis_invalid", state: this.read().state };
const result = index < 0
? call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence])

View File

@@ -98,6 +98,17 @@ export class WasmLinuxcncTp {
jog(axis, direction, increment, tag = 0) { if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" }; const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; }
jogVelocity(axis) { if (!this.available) return 0; const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[String(axis).toLowerCase()]; return index === undefined ? 0 : Number(call(this.core, "sim_linuxcnc_tp_jog_velocity", "number", ["number", "number"], [this.planner, index])); }
setPaused(value) { this.controls.paused = Boolean(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_paused", "number", ["number", "number"], [this.planner, this.controls.paused ? 1 : 0]); return this.read(); }
stepLine() {
const result = call(this.core, "sim_linuxcnc_tp_step_line", "number", ["number"], [this.planner]);
if (result !== 0) throw new Error(`linuxcnc_tp_step_line_${result}`);
this.controls.paused = false;
return this.read();
}
isStepping() { return Boolean(call(this.core, "sim_linuxcnc_tp_is_stepping", "number", ["number"], [this.planner])); }
axisLimits() {
const limits = [0, 1, 2].map((axis) => [0, 1].map((maximum) => Number(call(this.core, "sim_linuxcnc_tp_axis_limit", "number", ["number", "number", "number"], [this.planner, axis, maximum]))));
return limits.flat().every(Number.isFinite) ? limits : null;
}
enqueue(segment) {
if (!this.available || !segment) return { status: "skipped", code: "linuxcnc_tp_unavailable" };

View File

@@ -72,6 +72,8 @@ export class WasmProgramRuntime {
this.samplePointer = 0;
this.samplePosePointer = 0;
this.source = "";
this.hashedSource = null;
this.cachedSourceHash = null;
this.path = null;
this.subroutines = [];
this.executionSource = "";
@@ -95,6 +97,16 @@ export class WasmProgramRuntime {
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" };
}
get sourceHash() {
// Source text is immutable between file replacements. Only its identity
// needs checking on a tick; the canonical WASM state is still read fresh.
if (this.hashedSource !== this.source) {
this.hashedSource = this.source;
this.cachedSourceHash = this.source ? fnv1a64(this.source) : null;
}
return this.cachedSourceHash;
}
load(source, path = null) {
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
const bytes = new TextEncoder().encode(source);
@@ -140,6 +152,9 @@ export class WasmProgramRuntime {
if (loaded.status !== "verified") return loaded;
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
const state = this.snapshot();
const initialized = temporary.initializeCoordinates(state.coordinate_context, state);
if (initialized.status !== "verified") return initialized;
temporary.setRapidVelocity(this.rapidVelocity);
for (const subroutine of this.subroutines) {
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
@@ -156,11 +171,26 @@ export class WasmProgramRuntime {
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
if ([4, 5, 6].includes(step.program.status)) break;
}
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: this.sourceHash, line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
} finally { temporary.destroy(); }
}
start() { return this.invoke("sim_program_start", [], "program_start"); }
initializeCoordinates(context, state = {}) {
if (!context) return { status: "verified" };
const previous = this.snapshot().coordinate_context;
const blocks = ["G21"];
for (const [index, offsets] of context.offsets.entries()) {
if (offsets.some((value, axis) => value !== (previous?.offsets?.[index]?.[axis] ?? 0))) blocks.push(`G10 L2 P${index + 1} ${AXES.map((axis, a) => `${axis.toUpperCase()}${offsets[a]}`).join(" ")}`);
}
const system = context.system <= 6 ? `G${53 + context.system}` : `G59.${context.system - 6}`;
blocks.push(`${system} G${state.units === 20 ? 20 : 21} G${state.distance_mode === 91 ? 91 : 90}`);
for (const block of blocks) {
const result = this.executeBlock(block);
if (result.status !== "verified") return result;
}
return { status: "verified" };
}
synchronizePosition(value) {
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
@@ -208,10 +238,12 @@ export class WasmProgramRuntime {
restoreState(value) {
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
if (value.source_hash && value.source_hash !== fnv1a64(this.source)) throw new Error("wasm_program_restore_source_hash");
if (value.source_hash && value.source_hash !== this.sourceHash) throw new Error("wasm_program_restore_source_hash");
const subroutines = [...this.subroutines];
const loaded = this.load(this.source, this.path);
if (loaded.status !== "verified") throw new Error(loaded.code);
const initialized = this.initializeCoordinates(value.coordinate_context, value);
if (initialized.status !== "verified") throw new Error(initialized.code);
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
if (synchronized.status !== "verified") throw new Error(synchronized.code);
for (const subroutine of subroutines) {
@@ -232,6 +264,14 @@ export class WasmProgramRuntime {
} else if (status === "completed") {
this.start();
for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
} else if (status === "paused") {
const from = Number(value.pause_from ?? 2);
if (from !== 1) {
this.start();
if (from === 4) for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
}
const paused = this.pause();
if (paused.status !== "verified") throw new Error(paused.code);
} else if (status === "aborted") {
const aborted = this.abort();
if (aborted.status !== "verified") throw new Error(aborted.code);
@@ -305,7 +345,7 @@ export class WasmProgramRuntime {
const motionControl = { 0: "continuous", 1: "exact_path", 2: "exact_stop" }[state.motion_control_mode] ?? "continuous";
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
const executionMeta = state.current_line > 0 ? (this.executionLineMetadata[state.current_line - 1] ?? { path: this.path, line: state.current_line }) : { path: this.path, line: 0 };
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.sourceHash };
} finally { this.core._free(buffer); }
}