Keep XYZAC tool motion smooth and expose rotary DRO values

This commit is contained in:
2026-08-30 18:51:53 +08:00
parent c015709d26
commit 99fe5a4151
4 changed files with 104 additions and 35 deletions

View File

@@ -84,8 +84,10 @@ function DroView({ axes, state, coordType, displayType, metric, setCoordType, se
const format = (value: number, index: number) => (value * (index < 3 ? scale : 1)).toFixed(4);
const pose = (state.snapshot?.visual?.pose ?? state.snapshot?.authoritative?.kinematics?.pose) as Record<string, number> | undefined;
const commanded = [pose?.x, pose?.y, pose?.z, pose?.a, pose?.c].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0));
const actualPose = (state.snapshot?.visual?.pose ?? state.snapshot?.kinematics?.pose ?? {}) as Record<string, number>;
const actualAxes = [actualPose.x, actualPose.y, actualPose.z, actualPose.a, actualPose.c].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0));
const relative = state.snapshot?.coordinates?.relative ?? [0, 0, 0, 0, 0];
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = Number(axes[index] ?? 0); const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = actualAxes[index]; const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
const PROGRAM_ROW_HEIGHT = 17.4;

View File

@@ -87,8 +87,8 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let frame = 0;
let liveTick = -1;
let liveTrace: THREE.Vector3[] = [];
let pathKey = "";
let liveKey = "";
let pathSegments: Segment[] | null = null;
let segmentById = new Map<number, Segment>();
let bounds = { center: new THREE.Vector3(), radius: .12 };
let playbackQueue: Array<{ tick: number; pose: Point; joints: number[]; activeSegmentId: number; activeProgress: number }> = [];
let playbackStart = 0;
@@ -98,6 +98,10 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let renderedJoints: number[] = [];
let renderedActiveSegment = 0;
let renderedProgress = 0;
let liveHistoryLine: THREE.Line | null = null;
let activeLiveLine: THREE.Line | null = null;
let lastHistoryTick = -1;
let lastActiveKey = "";
let lastPreset = "";
const presetCamera = (selected: Props["view"], selectedZoom: number) => {
const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom);
@@ -131,12 +135,11 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const segments = state.toolpath as Segment[];
const layer = layersRef.current;
const now = performance.now();
if (state.tick < queuedLastTick) {
playbackQueue = [];
playbackStart = 0;
queuedLastTick = -1;
}
if (!state.motionTrace.length && state.tick > queuedLastTick) {
// React can deliver a full snapshot just behind an already queued
// motion-delta response. Keep the rendered pose monotonic until the
// newer samples arrive; otherwise the tool visibly jumps backward.
const staleState = state.tick < queuedLastTick;
if (!staleState && !state.motionTrace.length && state.tick > queuedLastTick) {
playbackQueue = [];
playbackStart = 0;
queuedLastTick = state.tick;
@@ -146,7 +149,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
}
for (const sample of state.motionTrace) {
for (const sample of staleState ? [] : state.motionTrace) {
if (sample.tick > queuedLastTick) {
playbackQueue.push(sample);
queuedLastTick = sample.tick;
@@ -156,16 +159,18 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
if (playbackQueue.length) {
const index = Math.min(playbackQueue.length - 1, Math.max(0, Math.floor((now - playbackStart) / 10)));
const sample = playbackQueue[index];
renderedTick = sample.tick;
renderedPose = sample.pose;
renderedJoints = sample.joints;
renderedActiveSegment = sample.activeSegmentId;
renderedProgress = sample.activeProgress;
if (sample.tick >= renderedTick) {
renderedTick = sample.tick;
renderedPose = sample.pose;
renderedJoints = sample.joints;
renderedActiveSegment = sample.activeSegmentId;
renderedProgress = sample.activeProgress;
}
if (index > 32) {
playbackQueue.splice(0, index);
playbackStart += index * 10;
}
} else {
} else if (!staleState) {
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
@@ -175,9 +180,14 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
// The planned path is immutable for a loaded program. Rebuilding all
// geometries on every animation frame starves worker snapshots and
// makes an otherwise continuous WASM trajectory appear to stutter.
const nextPathKey = segments.map((segment) => `${segment.id}:${segment.kind ?? ""}:${segment.samples?.length ?? 0}:${segment.start?.x ?? 0},${segment.start?.y ?? 0},${segment.start?.z ?? 0}:${segment.end?.x ?? 0},${segment.end?.y ?? 0},${segment.end?.z ?? 0}`).join("|");
if (nextPathKey !== pathKey) {
// Toolpath arrays are immutable between PROGRAM_LOAD/RESTART. Rebuild
// geometry only when WASM publishes a new array; scanning thousands of
// segments on every animation frame causes visible execution stalls.
if (segments !== pathSegments) {
pathSegments = segments;
segmentById = new Map(segments.flatMap((segment) => Number.isSafeInteger(segment.id) ? [[Number(segment.id), segment] as [number, Segment]] : []));
clearLines(pathGroup);
clearLines(liveGroup);
bounds = fit(segments);
// Long programs can contain thousands of linear blocks. Batch their
// vertices so preview loading does not block the UI on one WebGL
@@ -209,12 +219,18 @@ 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);
pathKey = nextPathKey;
liveKey = "";
liveHistoryLine = null;
activeLiveLine = null;
lastHistoryTick = -1;
lastActiveKey = "";
}
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
if (renderedTick < liveTick) liveTrace = [];
if (renderedTick < liveTick) {
liveTrace = [];
lastHistoryTick = -1;
if (liveHistoryLine) { liveHistoryLine.geometry.dispose(); liveGroup.remove(liveHistoryLine); liveHistoryLine = null; }
}
if (renderedTick !== liveTick) {
const current = sourcePoint(renderedPose);
const previous = liveTrace.at(-1);
@@ -222,15 +238,18 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
liveTick = renderedTick;
}
element.dataset.xyzacLiveTraceCount = String(liveTrace.length);
const nextLiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}:${liveTrace.length}`;
if (nextLiveKey !== liveKey) {
clearLines(liveGroup);
if (liveTrace.length > 1) {
const historyLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(liveTrace), liveHistory);
historyLine.renderOrder = 19;
liveGroup.add(historyLine);
}
const activeSegment = segments.find((segment) => Boolean(segment.active || segment.id === renderedActiveSegment));
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);
lastHistoryTick = renderedTick;
}
const nextActiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}`;
if (nextActiveKey !== lastActiveKey) {
if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; }
const activeSegment = segmentById.get(renderedActiveSegment) ?? segments.find((segment) => Boolean(segment.active));
if (activeSegment) {
const start = activeSegment.start ?? {}; const end = activeSegment.end ?? {};
const points = Array.isArray(activeSegment.samples) && activeSegment.samples.length > 1 ? activeSegment.samples.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
@@ -242,13 +261,13 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const lastPoint = travelled.at(-1);
if (!lastPoint || lastPoint.distanceTo(livePoint) > 1e-9) travelled.push(livePoint);
if (travelled.length > 1) {
const liveLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(travelled), live);
liveLine.renderOrder = 20;
liveGroup.add(liveLine);
activeLiveLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(travelled), live);
activeLiveLine.renderOrder = 20;
liveGroup.add(activeLiveLine);
}
}
}
liveKey = nextLiveKey;
lastActiveKey = nextActiveKey;
}
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4));

View File

@@ -89,7 +89,16 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
program: (result.program as Record<string, unknown>) ?? previous.snapshot.program,
motion: {
...previous.snapshot.motion,
axes: [Number(pose.x ?? previous.snapshot.motion?.axes?.[0] ?? 0), Number(pose.y ?? previous.snapshot.motion?.axes?.[1] ?? 0), Number(pose.z ?? previous.snapshot.motion?.axes?.[2] ?? 0)],
// Keep the XYZAC axis vector intact on every motion delta. Older
// projection code rebuilt only XYZ, which made A/C disappear from
// consumers that read the canonical motion array.
axes: [
Number(pose.x ?? previous.snapshot.motion?.axes?.[0] ?? 0),
Number(pose.y ?? previous.snapshot.motion?.axes?.[1] ?? 0),
Number(pose.z ?? previous.snapshot.motion?.axes?.[2] ?? 0),
Number(pose.a ?? previous.snapshot.motion?.axes?.[3] ?? 0),
Number(pose.c ?? previous.snapshot.motion?.axes?.[4] ?? 0),
],
joints,
},
wasm_motion: (result.wasm_motion as Record<string, unknown>) ?? previous.snapshot.wasm_motion,

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { INITIAL_STATE, reduceFrontendState } from "./store.ts";
const envelope = (kind, seq, payload, tick) => ({ schema_version: 1, kind, context_id: INITIAL_STATE.contextId, session_id: "motion-projection", seq, simulation_tick: tick, payload });
const panel = {
schema: "axis-panel-snapshot.v1",
context_id: INITIAL_STATE.contextId,
session_id: "motion-projection",
simulation_tick: 1,
state_hash: "fnv1a64:panel",
task: { state: "RUNNING", mode: "AUTO", program_status: "running", current_line: 1 },
program: { program_status: "running", current_line: 1 },
motion: { axes: [10, 20, 30, 40, 50], joints: [10, 20, 30, 40, 50] },
coordinates: { machine: [10, 20, 30, 40, 50], relative: [10, 20, 30, 40, 50] },
overrides: { feed: 1, spindle: [1] },
wasm_motion: { status: "verified", state: { position: { x: 10, y: 20, z: 30, a: 40, c: 50 }, queue_depth: 1 } },
visual: { schema: "axis-visual.v1", pose: { x: 10, y: 20, z: 30, a: 40, c: 50 }, joints: [10, 20, 30, 40, 50], toolpath: [], active_segment_id: 1, active_progress: 0.1, source: "wasm_canonical_motion" },
kinematics: { coordinates: "XYZAC", joint_count: 5, pose: { x: 10, y: 20, z: 30, a: 40, c: 50 }, joints: [10, 20, 30, 40, 50], offsets: {} },
xyzac: { coordinates: "XYZAC", joint_count: 5 },
gates: {},
};
const initial = reduceFrontendState(INITIAL_STATE, envelope("STATE_SNAPSHOT", 1, { panel_snapshot: panel }, 1));
const next = reduceFrontendState(initial, envelope("COMMAND_RESULT", 2, {
status: "verified",
snapshot_mode: "motion_delta",
motion_sample: { tick: 2, pose: { x: 11, y: 21, z: 31, a: 41, c: 51 }, joints: [11, 21, 31, 41, 51], active_segment_id: 1, active_progress: 0.2 },
motion_trace: [],
wasm_motion: { status: "verified", state: { position: { x: 11, y: 21, z: 31, a: 41, c: 51 }, queue_depth: 1 } },
motion: { axes: [11, 21, 31, 41, 51], joints: [11, 21, 31, 41, 51] },
task: panel.task,
program: panel.program,
state_hash: "fnv1a64:delta",
}, 2));
assert.deepEqual(next.snapshot.motion.axes, [11, 21, 31, 41, 51]);
assert.equal(next.snapshot.visual.pose.a, 41);
assert.equal(next.snapshot.visual.pose.c, 51);
console.log(JSON.stringify({ schema: "xyzac-motion-projection.v1", status: "verified", axes: next.snapshot.motion.axes, pose: { a: next.snapshot.visual.pose.a, c: next.snapshot.visual.pose.c } }));