Keep XYZAC tool motion smooth and expose rotary DRO values
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
web/react/src/test_xyzac_motion_projection.mjs
Normal file
39
web/react/src/test_xyzac_motion_projection.mjs
Normal 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 } }));
|
||||
Reference in New Issue
Block a user