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

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",