diff --git a/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs b/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs
index e2374ce..90a16ce 100644
--- a/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs
+++ b/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs
@@ -82,7 +82,13 @@ async function copyKinematicsRuntimeAssets() {
join(sdkSrcDir, "linuxcnc-kinematics.js"),
join(sdkDistDir, "linuxcnc-kinematics.js"),
);
- for (const entry of ["linuxcnc-interp.js", "linuxcnc-hal.js", "linuxcnc-tp.js", "linuxcnc-task-hal.js"]) {
+ for (const entry of [
+ "linuxcnc-interp.js",
+ "linuxcnc-hal.js",
+ "linuxcnc-tp.js",
+ "linuxcnc-task-hal.js",
+ "sim-config-staging.js",
+ ]) {
await cp(join(sdkSrcDir, entry), join(sdkDistDir, entry));
}
diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js b/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js
index 5dfe275..3f37941 100644
--- a/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js
+++ b/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js
@@ -88,20 +88,22 @@ export function validateFiveAxisSessionSnapshot(snapshot, sessionId) {
export async function saveFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
const snapshot = createFiveAxisSessionSnapshot(sessionId, payload, options);
const path = sessionSnapshotPath(sessionId, options.filename);
- await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, options.storage);
- return { snapshot, path };
+ const storage = resolveSessionStorage(options);
+ await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, storage.storage);
+ return { snapshot, path, storageMode: storage.mode };
}
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
const path = sessionSnapshotPath(sessionId, options.filename);
- const text = await loadTextFile(path, options.storage);
+ const storage = resolveSessionStorage(options);
+ const text = await loadTextFile(path, storage.storage);
let snapshot;
try {
snapshot = JSON.parse(text);
} catch (error) {
throw new Error(`Invalid five-axis session snapshot JSON: ${error.message}`);
}
- return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path };
+ return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path, storageMode: storage.mode };
}
export function restoreFiveAxisSessionState(snapshot) {
@@ -138,6 +140,7 @@ export function restoreFiveAxisSessionState(snapshot) {
export function createMemorySessionStorage(seed = {}) {
const files = new Map(Object.entries(seed));
return {
+ apiName: "web-rtcp-5axis-memory-session-storage",
files,
async getDirectory() {
return createDirectoryHandle(files, []);
@@ -145,6 +148,35 @@ export function createMemorySessionStorage(seed = {}) {
};
}
+let browserMemorySessionStorage = null;
+
+function resolveSessionStorage(options = {}) {
+ if (options.storage) {
+ return {
+ storage: options.storage,
+ mode: options.storageMode || storageModeFor(options.storage),
+ };
+ }
+ const browserStorage = globalThis.navigator?.storage;
+ if (browserStorage?.getDirectory) {
+ return {
+ storage: browserStorage,
+ mode: "opfs",
+ };
+ }
+ browserMemorySessionStorage ??= createMemorySessionStorage();
+ return {
+ storage: browserMemorySessionStorage,
+ mode: "memory-fallback",
+ };
+}
+
+function storageModeFor(storage) {
+ return storage?.apiName === "web-rtcp-5axis-memory-session-storage"
+ ? "memory"
+ : "custom";
+}
+
function validateFiveAxisSessionPayload(payload) {
assertPlainObject(payload, "five-axis session payload");
if (payload.apiName !== "web-rtcp-5axis-session-payload") {
diff --git a/web-rtcp-5axis-sim-plan/app/src/state/store.js b/web-rtcp-5axis-sim-plan/app/src/state/store.js
index 89d210b..fbddb71 100644
--- a/web-rtcp-5axis-sim-plan/app/src/state/store.js
+++ b/web-rtcp-5axis-sim-plan/app/src/state/store.js
@@ -59,6 +59,7 @@ const initialState = {
filename: DEFAULT_SESSION_FILENAME,
status: "not-saved",
path: null,
+ storageMode: null,
savedAt: null,
restoredAt: null,
lastError: null,
@@ -189,6 +190,7 @@ const initialState = {
pathPoints: 64,
selectedView: "iso",
fullscreen: false,
+ cameraRevision: 0,
},
toolPreview: {
toolNumber: 1,
@@ -629,10 +631,11 @@ export function createSimulationStore(seed = {}) {
...state.sessionPersistence,
status: "saved",
path: action.path,
+ storageMode: action.storageMode || null,
savedAt: action.savedAt,
lastError: null,
},
- operatorMessage: `5-axis session saved ${action.path}`,
+ operatorMessage: `5-axis session saved ${action.path} (${action.storageMode || "unknown"})`,
});
break;
case "SESSION_RESTORE_STARTED":
@@ -664,10 +667,11 @@ export function createSimulationStore(seed = {}) {
...state.sessionPersistence,
status: "restored",
path: action.path,
+ storageMode: action.storageMode || null,
restoredAt: action.restoredAt,
lastError: null,
},
- operatorMessage: `5-axis session restored ${action.path}`,
+ operatorMessage: `5-axis session restored ${action.path} (${action.storageMode || "unknown"})`,
});
}
break;
@@ -1255,7 +1259,11 @@ export function createSimulationStore(seed = {}) {
break;
case "RESET_VIEW":
setState({
- preview: { ...state.preview, selectedView: "iso" },
+ preview: {
+ ...state.preview,
+ selectedView: "iso",
+ cameraRevision: (state.preview.cameraRevision ?? 0) + 1,
+ },
operatorMessage: "preview fit to program",
});
break;
@@ -1267,7 +1275,11 @@ export function createSimulationStore(seed = {}) {
break;
case "SET_VIEW":
setState({
- preview: { ...state.preview, selectedView: action.view },
+ preview: {
+ ...state.preview,
+ selectedView: action.view,
+ cameraRevision: (state.preview.cameraRevision ?? 0) + 1,
+ },
operatorMessage: `preview view ${action.view}`,
});
break;
@@ -1395,17 +1407,19 @@ export function createSimulationStore(seed = {}) {
const sessionId = options.sessionId || state.sessionPersistence.sessionId;
const filename = options.filename || state.sessionPersistence.filename;
const payload = createFiveAxisSessionPayload(state);
- const { snapshot, path } = await saveFiveAxisSessionSnapshot(sessionId, payload, {
+ const { snapshot, path, storageMode } = await saveFiveAxisSessionSnapshot(sessionId, payload, {
filename,
storage: options.storage,
+ storageMode: options.storageMode,
metadata: options.metadata,
});
dispatch({
type: "SESSION_SAVE_COMPLETE",
path,
+ storageMode,
savedAt: snapshot.createdAt,
});
- return { snapshot, path };
+ return { snapshot, path, storageMode };
} catch (error) {
dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message });
throw error;
@@ -1417,18 +1431,20 @@ export function createSimulationStore(seed = {}) {
try {
const sessionId = options.sessionId || state.sessionPersistence.sessionId;
const filename = options.filename || state.sessionPersistence.filename;
- const { snapshot, path } = await loadFiveAxisSessionSnapshot(sessionId, {
+ const { snapshot, path, storageMode } = await loadFiveAxisSessionSnapshot(sessionId, {
filename,
storage: options.storage,
+ storageMode: options.storageMode,
});
dispatch({
type: "SESSION_RESTORE_COMPLETE",
restoredState: restoreFiveAxisSessionState(snapshot),
path,
+ storageMode,
restoredAt: new Date().toISOString(),
});
await refreshAsyncKinematicsFrame({ operatorMessage: `5-axis session restored ${path}` });
- return { snapshot, path };
+ return { snapshot, path, storageMode };
} catch (error) {
dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message });
throw error;
diff --git a/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css b/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css
index e437228..7d3f724 100644
--- a/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css
+++ b/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css
@@ -165,6 +165,12 @@ button:active {
height: calc(100% - 64px);
margin-top: 32px;
display: block;
+ touch-action: none;
+ cursor: grab;
+}
+
+.machine-preview:active {
+ cursor: grabbing;
}
.tool-preview-card {
diff --git a/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js b/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js
index 8cc83e5..751b1ae 100644
--- a/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js
+++ b/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js
@@ -324,7 +324,7 @@ function renderInfoTabs(element, state) {
Canonical:${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events
Switchkins:${formatSwitchkinsSummary(state.programExecution)}
Machine run:${formatMachineFileExecution(state.machineFileExecution)}
- Session:${state.sessionPersistence.status} / ${state.sessionPersistence.path ?? "-"}
+ Session:${state.sessionPersistence.status} / ${state.sessionPersistence.storageMode ?? "-"} / ${state.sessionPersistence.path ?? "-"}
Tool preview:T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}
Program time:${formatProgramTiming(state)}
Segment time:${formatProgramTimingSegment(state)}
diff --git a/web-rtcp-5axis-sim-plan/app/src/visualization/five-axis-scene.js b/web-rtcp-5axis-sim-plan/app/src/visualization/five-axis-scene.js
index ac08fb4..7480a02 100644
--- a/web-rtcp-5axis-sim-plan/app/src/visualization/five-axis-scene.js
+++ b/web-rtcp-5axis-sim-plan/app/src/visualization/five-axis-scene.js
@@ -1,6 +1,14 @@
import * as THREE from "../vendor/three/three.module.js";
const scenes = new WeakMap();
+const CAMERA_PRESETS = {
+ iso: { theta: -0.96, phi: 1.02, radius: 7.0, target: new THREE.Vector3(0, 0, 0) },
+ x: { theta: 0, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) },
+ y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) },
+ z: { theta: 0, phi: 0.001, radius: 6.6, target: new THREE.Vector3(0, 0, 0) },
+};
+const MAX_TOOLPATH_POINTS = 1600;
+const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]);
export function renderFiveAxisScene(canvas, state) {
let preview = scenes.get(canvas);
@@ -16,7 +24,7 @@ export function renderFiveAxisScene(canvas, state) {
try {
resizeRenderer(preview);
- updateMachinePose(preview, state);
+ updateToolpathPreview(preview, state);
preview.renderer.render(preview.scene, preview.camera);
} catch (error) {
const fallback = createFallbackPreview(canvas, error);
@@ -25,12 +33,17 @@ export function renderFiveAxisScene(canvas, state) {
return;
}
- const pointCount = preview.pathLine.geometry.getAttribute("position").count;
+ const pointCount = preview.previewPath.geometry.getAttribute("position").count;
+ const executedPointCount = preview.executedPath.geometry.getAttribute("position").count;
exposePreviewDataset(canvas, state, {
pointCount,
+ executedPointCount,
sceneObjectCount: countSceneObjects(preview.scene),
- toolhead: preview.toolGroup.position,
+ toolhead: preview.currentToolhead,
renderer: "webgl",
+ sceneMode: "program-preview-and-tool-execution",
+ cameraControls: preview.controls.enabled,
+ toolExecutionMarker: preview.toolMarker.visible,
});
}
@@ -48,87 +61,45 @@ function createScene(canvas) {
antialias: true,
preserveDrawingBuffer: true,
});
- renderer.setClearColor(0x07100d, 1);
+ renderer.setClearColor(0x030405, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
- scene.fog = new THREE.Fog(0x07100d, 7, 15);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
- camera.position.set(3.4, -5.0, 3.2);
- camera.lookAt(0, 0, 0);
- const ambient = new THREE.HemisphereLight(0xdffbff, 0x17110d, 0.86);
- const key = new THREE.DirectionalLight(0xffffff, 1.35);
- key.position.set(3, -5, 7);
- const fill = new THREE.DirectionalLight(0x4fd5ff, 0.46);
- fill.position.set(-4, 3, 3);
- scene.add(ambient, key, fill);
-
- const floor = new THREE.Mesh(
- new THREE.BoxGeometry(6.2, 4.5, 0.08),
- new THREE.MeshStandardMaterial({ color: 0x151b1f, roughness: 0.78, metalness: 0.12 }),
- );
- floor.position.z = -0.98;
- scene.add(floor);
-
- const grid = new THREE.GridHelper(6.2, 14, 0x4b555a, 0x252f32);
- grid.rotation.x = Math.PI / 2;
- grid.position.z = -0.93;
- scene.add(grid);
-
- const axes = new THREE.AxesHelper(1.45);
- axes.position.set(-2.85, -2.0, -0.86);
- scene.add(axes);
-
- const envelope = buildEnvelope();
- scene.add(envelope);
-
- const tableGroup = new THREE.Group();
- const table = new THREE.Mesh(
- new THREE.BoxGeometry(3.0, 2.15, 0.18),
- new THREE.MeshStandardMaterial({ color: 0x42484e, roughness: 0.68, metalness: 0.22 }),
- );
- const platter = new THREE.Mesh(
- new THREE.CylinderGeometry(0.84, 0.84, 0.16, 64),
- new THREE.MeshStandardMaterial({ color: 0x6d7880, roughness: 0.48, metalness: 0.42 }),
- );
- platter.rotation.x = Math.PI / 2;
- platter.position.z = 0.14;
- tableGroup.add(table, platter);
- scene.add(tableGroup);
-
- const pathLine = buildToolpath();
- scene.add(pathLine);
-
- const toolGroup = new THREE.Group();
- const toolBody = new THREE.Mesh(
- new THREE.CylinderGeometry(0.045, 0.07, 1.05, 28),
- new THREE.MeshStandardMaterial({ color: 0x29ecf0, emissive: 0x0a4f52, roughness: 0.28 }),
- );
- toolBody.rotation.x = Math.PI / 2;
- toolBody.position.z = 0.52;
- const tcpPoint = new THREE.Mesh(
- new THREE.SphereGeometry(0.095, 28, 18),
- new THREE.MeshStandardMaterial({ color: 0x1ffff4, emissive: 0x0b6f6f, roughness: 0.18 }),
+ const previewPath = createLine(0x808892, 0.56);
+ const executedPath = createLine(0x1ffff4, 1);
+ const rapidPath = createLine(0xffb13b, 0.82);
+ const toolMarker = new THREE.Mesh(
+ new THREE.SphereGeometry(0.065, 18, 12),
+ new THREE.MeshBasicMaterial({ color: 0x1ffff4 }),
);
const toolAxis = new THREE.Line(
- new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]),
- new THREE.LineBasicMaterial({ color: 0x21f2f2, linewidth: 2 }),
+ EMPTY_GEOMETRY.clone(),
+ new THREE.LineBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.9 }),
);
- toolGroup.add(toolBody, tcpPoint, toolAxis);
- scene.add(toolGroup);
+ scene.add(previewPath, rapidPath, executedPath, toolAxis, toolMarker);
+ const controls = createToolpathCameraControls(canvas, camera, () => {
+ renderer.render(scene, camera);
+ });
const preview = {
kind: "webgl",
renderer,
scene,
camera,
- tableGroup,
- toolGroup,
+ controls,
+ previewPath,
+ executedPath,
+ rapidPath,
+ toolMarker,
toolAxis,
- pathLine,
+ currentToolhead: new THREE.Vector3(),
+ lastSelectedView: null,
+ lastCameraRevision: null,
};
resizeRenderer(preview);
+ resetCamera(preview, "iso");
return preview;
}
@@ -153,101 +124,65 @@ function renderFallbackPreview(preview, state) {
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
- ctx.fillStyle = "#07100d";
+ ctx.fillStyle = "#030405";
ctx.fillRect(0, 0, width, height);
const cx = width * 0.5;
const cy = height * 0.53;
const scale = Math.min(width / 7.2, height / 4.8);
- drawFallbackGrid(ctx, cx, cy, scale);
-
- ctx.strokeStyle = "#e43a35";
- ctx.lineWidth = 1.5;
- ctx.strokeRect(cx - 2.95 * scale, cy - 1.9 * scale, 5.9 * scale, 3.8 * scale);
-
- ctx.fillStyle = "#42484e";
- ctx.strokeStyle = "#79838a";
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.roundRect(cx - 1.5 * scale, cy - 0.78 * scale, 3.0 * scale, 1.56 * scale, 4);
- ctx.fill();
- ctx.stroke();
-
- ctx.fillStyle = "#6d7880";
- ctx.strokeStyle = "#a3b0b8";
- ctx.beginPath();
- ctx.ellipse(cx, cy, 0.84 * scale, 0.48 * scale, 0, 0, Math.PI * 2);
- ctx.fill();
- ctx.stroke();
-
- ctx.strokeStyle = "#ffffff";
- ctx.lineWidth = 2;
- ctx.beginPath();
- for (let index = 0; index < 72; index += 1) {
- const t = index / 71;
- const x = cx + (-2.45 + t * 4.9) * scale;
- const y = cy + Math.sin(t * Math.PI * 13) * 0.36 * scale;
- if (index === 0) ctx.moveTo(x, y);
- else ctx.lineTo(x, y);
+ const previewPoints = buildProgramPreviewPoints(state);
+ const executedPoints = buildExecutedProgramPoints(state, previewPoints);
+ const pointCount = previewPoints.length;
+ const executedPointCount = executedPoints.length;
+ if (pointCount > 0) {
+ ctx.strokeStyle = "#8d95a0";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ drawFallbackPolyline(ctx, previewPoints, cx, cy, scale);
+ ctx.stroke();
}
- ctx.stroke();
- const tcp = state.tcpPose;
- const tool = state.toolAxisVector;
- const toolX = cx + clamp(tcp.x * 0.035, -2.7, 2.7) * scale;
- const toolY = cy - clamp(tcp.y * 0.035, -2.0, 2.0) * scale;
- const axisX = toolX + tool.x * 0.85 * scale;
- const axisY = toolY - (tool.y || 0.2) * 0.85 * scale;
- ctx.strokeStyle = "#21f2f2";
- ctx.fillStyle = "#1ffff4";
- ctx.lineWidth = 3;
- ctx.beginPath();
- ctx.moveTo(toolX, toolY);
- ctx.lineTo(axisX, axisY);
- ctx.stroke();
- ctx.beginPath();
- ctx.arc(toolX, toolY, 0.09 * scale, 0, Math.PI * 2);
- ctx.fill();
+ if (executedPointCount > 0) {
+ ctx.strokeStyle = "#1ffff4";
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ drawFallbackPolyline(ctx, executedPoints, cx, cy, scale);
+ ctx.stroke();
+ }
+
+ const toolPosition = executionToolPosition(state, previewPoints);
+ if (toolPosition) {
+ const toolX = cx + toolPosition.x * scale;
+ const toolY = cy - toolPosition.y * scale;
+ ctx.fillStyle = "#1ffff4";
+ ctx.beginPath();
+ ctx.arc(toolX, toolY, 0.055 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
ctx.fillStyle = "#b7c7b8";
ctx.font = "12px Courier New, monospace";
ctx.fillText("2D RTCP fallback", 12, height - 14);
exposePreviewDataset(canvas, state, {
- pointCount: 72,
- sceneObjectCount: 12,
- toolhead: {
- x: clamp(tcp.x * 0.035, -2.7, 2.7),
- y: clamp(tcp.y * 0.035, -2.0, 2.0),
- z: clamp(tcp.z * 0.04 + 0.35, -1.1, 1.9),
- },
+ pointCount,
+ executedPointCount,
+ sceneObjectCount: (pointCount > 0 ? 1 : 0) + (executedPointCount > 0 ? 1 : 0),
+ toolhead: toolPosition || { x: 0, y: 0, z: 0 },
renderer: "2d-fallback",
+ sceneMode: "program-preview-and-tool-execution",
+ cameraControls: false,
+ toolExecutionMarker: Boolean(toolPosition),
});
canvas.dataset.threeFallbackReason = preview.errorMessage;
}
-function drawFallbackGrid(ctx, cx, cy, scale) {
- ctx.strokeStyle = "#273236";
- ctx.lineWidth = 1;
- for (let x = -3; x <= 3; x += 0.5) {
- ctx.beginPath();
- ctx.moveTo(cx + x * scale, cy - 2.1 * scale);
- ctx.lineTo(cx + x * scale, cy + 2.1 * scale);
- ctx.stroke();
- }
- for (let y = -2; y <= 2; y += 0.5) {
- ctx.beginPath();
- ctx.moveTo(cx - 3.1 * scale, cy + y * scale);
- ctx.lineTo(cx + 3.1 * scale, cy + y * scale);
- ctx.stroke();
- }
-}
-
function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeReady = "true";
canvas.dataset.threeRevision = THREE.REVISION;
canvas.dataset.threePathPoints = String(preview.pointCount);
+ canvas.dataset.threeExecutedPathPoints = String(preview.executedPointCount);
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
@@ -256,62 +191,346 @@ function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeSelectedView = state.preview.selectedView;
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName;
canvas.dataset.threeRenderer = preview.renderer;
+ canvas.dataset.threeSceneMode = preview.sceneMode;
+ canvas.dataset.threeCameraControls = preview.cameraControls ? "orbit-pan-zoom" : "none";
+ canvas.dataset.threeProgramPreviewSource = previewSourceMode(state);
+ canvas.dataset.threeToolExecutionMarker = preview.toolExecutionMarker ? "true" : "false";
}
-function buildEnvelope() {
- const geometry = new THREE.BoxGeometry(5.9, 4.25, 2.7);
- const edges = new THREE.EdgesGeometry(geometry);
- const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe43a35 }));
- line.position.z = 0.2;
- return line;
+function createLine(color, opacity) {
+ return new THREE.Line(
+ EMPTY_GEOMETRY.clone(),
+ new THREE.LineBasicMaterial({
+ color,
+ transparent: opacity < 1,
+ opacity,
+ }),
+ );
}
-function buildToolpath() {
+function updateToolpathPreview(preview, state) {
+ const previewPoints = buildProgramPreviewPoints(state);
+ const executedPoints = buildExecutedProgramPoints(state, previewPoints);
+ const rapidPoints = buildRapidPreviewPoints(state);
+ const toolPosition = executionToolPosition(state, previewPoints);
+
+ updateLineGeometry(preview.previewPath, previewPoints);
+ updateLineGeometry(preview.executedPath, executedPoints);
+ updateLineGeometry(preview.rapidPath, rapidPoints);
+ updateToolExecutionMarker(preview, state, toolPosition);
+
+ const cameraRevision = state.preview.cameraRevision ?? 0;
+ if (
+ preview.lastSelectedView !== state.preview.selectedView ||
+ preview.lastCameraRevision !== cameraRevision
+ ) {
+ resetCamera(preview, state.preview.selectedView);
+ preview.lastSelectedView = state.preview.selectedView;
+ preview.lastCameraRevision = cameraRevision;
+ } else {
+ applyCameraControls(preview.controls);
+ }
+}
+
+function updateToolExecutionMarker(preview, state, toolPosition) {
+ if (!toolPosition) {
+ preview.currentToolhead.set(0, 0, 0);
+ preview.toolMarker.visible = false;
+ preview.toolAxis.visible = false;
+ updateLineGeometry(preview.toolAxis, []);
+ return;
+ }
+
+ const vector = toToolVector(state.toolAxisVector);
+ preview.currentToolhead.copy(toolPosition);
+ preview.toolMarker.visible = true;
+ preview.toolMarker.position.copy(toolPosition);
+ preview.toolAxis.visible = true;
+ updateLineGeometry(preview.toolAxis, [
+ toolPosition,
+ toolPosition.clone().add(vector.multiplyScalar(0.7)),
+ ]);
+}
+
+function updateLineGeometry(line, points) {
+ line.visible = points.length > 0;
+ line.geometry.dispose();
+ line.geometry = points.length > 0
+ ? new THREE.BufferGeometry().setFromPoints(points)
+ : EMPTY_GEOMETRY.clone();
+}
+
+function buildProgramPreviewPoints(state) {
+ const motion = state.programExecution?.motion;
+ if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) {
+ return limitPoints(motion.map((event) => vectorFromAxes(event.axes)));
+ }
+
+ const pointCount = normalizePathPointCount(state.preview.pathPoints);
+ if (pointCount === 0) return [];
+ return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose));
+}
+
+function buildExecutedProgramPoints(state, previewPoints) {
+ if (state.preview.pathPoints === 0 || previewPoints.length === 0) return [];
+ const samples = state.programExecutionTiming?.samples;
+ const sampleIndex = Number(state.programExecutionSampleIndex || 0);
+ if (Array.isArray(samples) && samples.length > 0) {
+ const end = clamp(Math.round(sampleIndex), 0, samples.length - 1);
+ return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample)));
+ }
+
+ const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1);
+ return previewPoints.slice(0, motionIndex + 1);
+}
+
+function buildRapidPreviewPoints(state) {
+ const motion = state.programExecution?.motion;
+ if (!Array.isArray(motion) || state.preview.pathPoints === 0) return [];
+ return limitPoints(
+ motion
+ .filter((event) => event.type === "STRAIGHT_TRAVERSE")
+ .map((event) => vectorFromAxes(event.axes)),
+ );
+}
+
+function buildFixturePreviewPoints(pointCount, tcpPosition) {
const points = [];
- for (let index = 0; index < 72; index += 1) {
- const t = index / 71;
+ for (let index = 0; index < pointCount; index += 1) {
+ const t = pointCount === 1 ? 0 : index / (pointCount - 1);
const x = -2.45 + t * 4.9;
const y = Math.sin(t * Math.PI * 13) * 0.36;
const z = -0.68 + Math.sin(t * Math.PI * 2) * 0.42;
points.push(new THREE.Vector3(x, y, z));
}
- const geometry = new THREE.BufferGeometry().setFromPoints(points);
- return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xffffff }));
-}
-
-function updateMachinePose(preview, state) {
- const tcp = state.tcpPose;
- const tool = state.toolAxisVector;
- const tcpPosition = new THREE.Vector3(
- clamp(tcp.x * 0.035, -2.7, 2.7),
- clamp(tcp.y * 0.035, -2.0, 2.0),
- clamp(tcp.z * 0.04 + 0.35, -1.1, 1.9),
- );
- const toolAxisVector = new THREE.Vector3(tool.x, tool.y, tool.z || 1).normalize();
-
- preview.toolGroup.position.copy(tcpPosition);
- preview.toolGroup.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), toolAxisVector);
- preview.toolAxis.geometry.setFromPoints([
- new THREE.Vector3(0, 0, 0),
- toolAxisVector.clone().multiplyScalar(state.rtcpState === "on" ? 1.2 : 0.85),
- ]);
- preview.tableGroup.rotation.x = THREE.MathUtils.degToRad(state.axisPose.a);
- preview.tableGroup.rotation.z = THREE.MathUtils.degToRad(state.axisPose.c);
- setCameraView(preview.camera, state.preview.selectedView);
-}
-
-function setCameraView(camera, selectedView) {
- if (selectedView === "x") {
- camera.position.set(6, 0.02, 0.45);
- } else if (selectedView === "y") {
- camera.position.set(0.02, -6, 0.7);
- } else if (selectedView === "z") {
- camera.position.set(0.01, -0.02, 6.6);
- } else {
- camera.position.set(3.4, -5.0, 3.2);
+ if (points.length > 0 && tcpPosition) {
+ points[points.length - 1] = tcpPosition.clone();
}
- camera.lookAt(0, 0, 0);
- camera.updateProjectionMatrix();
+ return points;
+}
+
+function executionToolPosition(state, previewPoints) {
+ if (state.preview.pathPoints === 0) return null;
+ const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback;
+ if (feedbackAxes && hasLinearAxes(feedbackAxes)) return vectorFromAxes(feedbackAxes);
+ if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose);
+ return previewPoints.at(-1) || null;
+}
+
+function vectorFromAxes(axes = {}) {
+ return new THREE.Vector3(
+ scaleLinearAxis(axes.x),
+ scaleLinearAxis(axes.y),
+ scaleZAxis(axes.z),
+ );
+}
+
+function toPreviewVector(pose = {}) {
+ return new THREE.Vector3(
+ scaleLinearAxis(pose.x),
+ scaleLinearAxis(pose.y),
+ scaleZAxis(pose.z),
+ );
+}
+
+function scaleLinearAxis(value) {
+ return clamp((Number(value) || 0) * 0.035, -2.7, 2.7);
+}
+
+function scaleZAxis(value) {
+ return clamp((Number(value) || 0) * 0.04 + 0.35, -1.1, 1.9);
+}
+
+function toToolVector(vector = {}) {
+ return new THREE.Vector3(
+ Number(vector.x) || 0,
+ Number(vector.y) || 0,
+ Number(vector.z) || 1,
+ ).normalize();
+}
+
+function hasLinearAxes(value = {}) {
+ return ["x", "y", "z"].some((axis) => Number.isFinite(Number(value[axis])));
+}
+
+function limitPoints(points) {
+ if (points.length <= MAX_TOOLPATH_POINTS) return points;
+ const stride = Math.ceil(points.length / MAX_TOOLPATH_POINTS);
+ const sampled = points.filter((_, index) => index % stride === 0);
+ const last = points.at(-1);
+ if (last && sampled.at(-1) !== last) sampled.push(last);
+ return sampled;
+}
+
+function previewSourceMode(state) {
+ if (state.programExecution?.sourceMode) return state.programExecution.sourceMode;
+ return state.programExecutionSourceMode || "fixture-line-playback";
+}
+
+function drawFallbackPolyline(ctx, points, cx, cy, scale) {
+ for (let index = 0; index < points.length; index += 1) {
+ const point = points[index];
+ const x = cx + point.x * scale;
+ const y = cy - point.y * scale;
+ if (index === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+}
+
+function createToolpathCameraControls(canvas, camera, renderFrame) {
+ const controls = {
+ enabled: true,
+ camera,
+ target: new THREE.Vector3(),
+ theta: CAMERA_PRESETS.iso.theta,
+ phi: CAMERA_PRESETS.iso.phi,
+ radius: CAMERA_PRESETS.iso.radius,
+ pointerMode: null,
+ pointers: new Map(),
+ lastPointer: null,
+ lastPinchCenter: null,
+ lastPinchDistance: 0,
+ renderFrame,
+ };
+
+ canvas.addEventListener("contextmenu", (event) => event.preventDefault());
+ canvas.addEventListener("wheel", (event) => {
+ event.preventDefault();
+ const scale = Math.exp(Math.sign(event.deltaY) * 0.12);
+ controls.radius = clamp(controls.radius * scale, 1.2, 28);
+ applyCameraControls(controls);
+ controls.renderFrame();
+ }, { passive: false });
+ canvas.addEventListener("pointerdown", (event) => {
+ safelySetPointerCapture(canvas, event.pointerId);
+ controls.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
+ controls.lastPointer = { x: event.clientX, y: event.clientY };
+ controls.pointerMode = event.button === 1 || event.button === 2 || event.shiftKey ? "pan" : "rotate";
+ if (controls.pointers.size === 2) {
+ controls.pointerMode = "pinch";
+ controls.lastPinchDistance = getPointerDistance(controls.pointers);
+ controls.lastPinchCenter = getPointerCenter(controls.pointers);
+ }
+ });
+ canvas.addEventListener("pointermove", (event) => {
+ if (!controls.pointers.has(event.pointerId)) return;
+ const previous = controls.pointers.get(event.pointerId);
+ controls.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
+
+ if (controls.pointerMode === "pinch" && controls.pointers.size >= 2) {
+ const distance = getPointerDistance(controls.pointers);
+ const center = getPointerCenter(controls.pointers);
+ if (distance > 0 && controls.lastPinchDistance > 0) {
+ controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 1.2, 28);
+ if (controls.lastPinchCenter) {
+ panCamera(
+ controls,
+ center.x - controls.lastPinchCenter.x,
+ center.y - controls.lastPinchCenter.y,
+ canvas,
+ );
+ }
+ controls.lastPinchDistance = distance;
+ controls.lastPinchCenter = center;
+ applyCameraControls(controls);
+ controls.renderFrame();
+ }
+ return;
+ }
+
+ const dx = event.clientX - previous.x;
+ const dy = event.clientY - previous.y;
+ if (controls.pointerMode === "pan") {
+ panCamera(controls, dx, dy, canvas);
+ } else {
+ controls.theta -= dx * 0.006;
+ controls.phi = clamp(controls.phi - dy * 0.006, 0.001, Math.PI - 0.001);
+ }
+ applyCameraControls(controls);
+ controls.renderFrame();
+ });
+
+ const releasePointer = (event) => {
+ controls.pointers.delete(event.pointerId);
+ safelyReleasePointerCapture(canvas, event.pointerId);
+ if (controls.pointers.size === 0) {
+ controls.pointerMode = null;
+ controls.lastPointer = null;
+ controls.lastPinchCenter = null;
+ controls.lastPinchDistance = 0;
+ }
+ };
+ canvas.addEventListener("pointerup", releasePointer);
+ canvas.addEventListener("pointercancel", releasePointer);
+ return controls;
+}
+
+function safelySetPointerCapture(canvas, pointerId) {
+ try {
+ canvas.setPointerCapture?.(pointerId);
+ } catch {
+ // Synthetic browser-smoke events do not always have an active pointer capture target.
+ }
+}
+
+function safelyReleasePointerCapture(canvas, pointerId) {
+ try {
+ canvas.releasePointerCapture?.(pointerId);
+ } catch {
+ // Matching setPointerCapture guard for synthetic and cancelled pointer streams.
+ }
+}
+
+function normalizePathPointCount(value) {
+ const requestedCount = Number(value);
+ return Number.isFinite(requestedCount) && requestedCount > 0
+ ? Math.max(2, Math.min(Math.round(requestedCount), 512))
+ : 0;
+}
+
+function getPointerDistance(pointers) {
+ const values = [...pointers.values()];
+ if (values.length < 2) return 0;
+ return Math.hypot(values[0].x - values[1].x, values[0].y - values[1].y);
+}
+
+function getPointerCenter(pointers) {
+ const values = [...pointers.values()];
+ if (values.length < 2) return null;
+ return {
+ x: (values[0].x + values[1].x) / 2,
+ y: (values[0].y + values[1].y) / 2,
+ };
+}
+
+function panCamera(controls, dx, dy, canvas) {
+ const cameraDirection = new THREE.Vector3();
+ controls.camera.getWorldDirection(cameraDirection);
+ const right = new THREE.Vector3().crossVectors(cameraDirection, controls.camera.up).normalize();
+ const up = new THREE.Vector3().crossVectors(right, cameraDirection).normalize();
+ const speed = controls.radius / Math.max(canvas.clientWidth, canvas.clientHeight, 1);
+ controls.target.addScaledVector(right, -dx * speed);
+ controls.target.addScaledVector(up, dy * speed);
+}
+
+function resetCamera(preview, selectedView) {
+ const preset = CAMERA_PRESETS[selectedView] || CAMERA_PRESETS.iso;
+ preview.controls.theta = preset.theta;
+ preview.controls.phi = preset.phi;
+ preview.controls.radius = preset.radius;
+ preview.controls.target.copy(preset.target);
+ applyCameraControls(preview.controls);
+}
+
+function applyCameraControls(controls) {
+ const sinPhiRadius = Math.sin(controls.phi) * controls.radius;
+ controls.camera.position.set(
+ controls.target.x + sinPhiRadius * Math.cos(controls.theta),
+ controls.target.y + sinPhiRadius * Math.sin(controls.theta),
+ controls.target.z + Math.cos(controls.phi) * controls.radius,
+ );
+ controls.camera.lookAt(controls.target);
+ controls.camera.updateProjectionMatrix();
}
function resizeRenderer(preview) {
diff --git a/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html b/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html
index 1172908..4ed5c61 100644
--- a/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html
+++ b/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html
@@ -76,11 +76,14 @@
if (
canvas.dataset.threeReady !== "true" ||
canvas.dataset.threeFrameApi !== "web-rtcp-5axis-motion-frame" ||
+ canvas.dataset.threeSceneMode !== "program-preview-and-tool-execution" ||
+ canvas.dataset.threeCameraControls !== "orbit-pan-zoom" ||
Number(canvas.dataset.threePathPoints ?? 0) < 64 ||
- Number(canvas.dataset.threeSceneObjects ?? 0) < 12 ||
+ Number(canvas.dataset.threeSceneObjects ?? 0) < 5 ||
!canvas.dataset.threeToolhead ||
!canvas.dataset.threeToolAxis ||
- !canvas.dataset.threeTcpPose
+ !canvas.dataset.threeTcpPose ||
+ canvas.dataset.threeToolExecutionMarker !== "true"
) {
throw new Error(`Three.js preview did not expose ready render state: ${JSON.stringify(canvas.dataset)}`);
}
@@ -269,6 +272,14 @@
if (win.webRtcp5AxisSimulation.getState().programRuntimeFeedback?.semanticBoundary !== "linuxcnc_tp_run_cycle_feedback_without_hardware") {
throw new Error(`initial runtime feedback did not use LinuxCNC TP sample: ${JSON.stringify(win.webRtcp5AxisSimulation.getState().programRuntimeFeedback)}`);
}
+ canvas = doc.querySelector("[data-five-axis-canvas]");
+ if (
+ canvas.dataset.threeProgramPreviewSource !== "linuxcnc-interpreter-wasm" ||
+ Number(canvas.dataset.threePathPoints ?? 0) < win.webRtcp5AxisSimulation.getState().programExecution.summary.motionEventCount ||
+ Number(canvas.dataset.threeExecutedPathPoints ?? 0) < 1
+ ) {
+ throw new Error(`Three.js preview did not consume LinuxCNC program execution path: ${JSON.stringify(canvas.dataset)}`);
+ }
if (!doc.querySelector('[data-program-execution-source]')?.textContent.includes("linuxcnc-interpreter-wasm")) {
throw new Error("program execution source DOM did not render interpreter source");
}
@@ -369,9 +380,15 @@
if (savedSession.snapshot.format !== "web-rtcp-5axis-session-snapshot") {
throw new Error("saveSession did not write a five-axis session snapshot");
}
+ if (!["opfs", "memory-fallback"].includes(savedSession.storageMode)) {
+ throw new Error(`saveSession used unexpected storage mode: ${savedSession.storageMode}`);
+ }
if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes("saved")) {
throw new Error("session save status did not render");
}
+ if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes(savedSession.storageMode)) {
+ throw new Error("session save status did not render storage mode");
+ }
if (doc.querySelector('[data-active-program-line]')?.textContent !== "Current line 2") {
throw new Error("loaded program did not render first LinuxCNC motion line");
}
@@ -393,6 +410,13 @@
if (Number(doc.querySelector(".gcode-row.active")?.dataset.programLine || 0) < 2) {
throw new Error("RUN did not highlight a LinuxCNC task/HAL motion line");
}
+ canvas = doc.querySelector("[data-five-axis-canvas]");
+ if (
+ Number(canvas.dataset.threeExecutedPathPoints ?? 0) < 1 ||
+ canvas.dataset.threeToolExecutionMarker !== "true"
+ ) {
+ throw new Error(`Three.js preview did not display tool execution progress: ${JSON.stringify(canvas.dataset)}`);
+ }
const taskHalRunState = win.webRtcp5AxisSimulation.getState();
if (taskHalRunState.fullExecutionBoundary?.semanticBoundary !== "linuxcnc_task_motion_hal_wasm_simulation_runtime") {
throw new Error(`full execution boundary did not promote task/HAL simulation runtime: ${JSON.stringify(taskHalRunState.fullExecutionBoundary)}`);
@@ -504,6 +528,23 @@
if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes("restored")) {
throw new Error("session restore status did not render");
}
+ if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes(savedSession.storageMode)) {
+ throw new Error("session restore status did not preserve storage mode");
+ }
+ doc.querySelector('[data-action="SAVE_SESSION"]').click();
+ await wait(120);
+ const uiSavedSession = win.webRtcp5AxisSimulation.getState().sessionPersistence;
+ if (uiSavedSession.status !== "saved" || !["opfs", "memory-fallback"].includes(uiSavedSession.storageMode)) {
+ throw new Error(`UI Save Session did not use a supported storage mode: ${JSON.stringify(uiSavedSession)}`);
+ }
+ doc.querySelector('[data-action="JOG_X_POS"]').click();
+ await wait(50);
+ doc.querySelector('[data-action="RESTORE_SESSION"]').click();
+ await wait(180);
+ const uiRestoredSession = win.webRtcp5AxisSimulation.getState().sessionPersistence;
+ if (uiRestoredSession.status !== "restored" || uiRestoredSession.storageMode !== uiSavedSession.storageMode) {
+ throw new Error(`UI Restore Session did not restore from default storage fallback: ${JSON.stringify(uiRestoredSession)}`);
+ }
if (win.webRtcp5AxisSimulation.getState().machine.allHomed !== true) {
doc.querySelector('[data-action="mode-manual"]').click();
await wait(50);
@@ -569,11 +610,46 @@
if (win.webRtcp5AxisSimulation.getState().preview.selectedView !== "x") {
throw new Error("preview view button did not update state");
}
+ canvas = doc.querySelector("[data-five-axis-canvas]");
+ canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
+ canvas.dispatchEvent(new PointerEvent("pointerdown", {
+ pointerId: 1,
+ clientX: 180,
+ clientY: 120,
+ button: 0,
+ bubbles: true,
+ }));
+ canvas.dispatchEvent(new PointerEvent("pointermove", {
+ pointerId: 1,
+ clientX: 220,
+ clientY: 135,
+ button: 0,
+ bubbles: true,
+ }));
+ canvas.dispatchEvent(new PointerEvent("pointerup", {
+ pointerId: 1,
+ clientX: 220,
+ clientY: 135,
+ button: 0,
+ bubbles: true,
+ }));
+ await wait(50);
+ if (canvas.dataset.threeCameraControls !== "orbit-pan-zoom") {
+ throw new Error("Three.js preview did not expose orbit/pan/zoom controls");
+ }
+ assertCanvasNonblank(canvas, "interactive Three.js preview");
doc.querySelector('[data-action="clear-preview"]').click();
await wait(50);
if (win.webRtcp5AxisSimulation.getState().preview.pathPoints !== 0) {
throw new Error("clear preview button did not update path points");
}
+ canvas = doc.querySelector("[data-five-axis-canvas]");
+ if (Number(canvas.dataset.threePathPoints ?? -1) !== 0) {
+ throw new Error(`Three.js preview did not clear toolpath points: ${JSON.stringify(canvas.dataset)}`);
+ }
+ if (Number(canvas.dataset.threeExecutedPathPoints ?? -1) !== 0) {
+ throw new Error(`Three.js preview did not clear executed toolpath points: ${JSON.stringify(canvas.dataset)}`);
+ }
doc.querySelector('[data-action="RELOAD"]').click();
await wait(50);
if (win.webRtcp5AxisSimulation.getState().preview.pathPoints !== 8) {
diff --git a/web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs b/web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs
index eb1e591..bd19a9d 100644
--- a/web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs
+++ b/web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs
@@ -49,6 +49,8 @@ const storage = createMemorySessionStorage();
const saved = await store.saveSession({ storage });
assert.equal(saved.snapshot.format, "web-rtcp-5axis-session-snapshot");
assert.equal(saved.path, "web-rtcp-5axis-sim-plan/sessions/gmoccapy-web-session/web-rtcp-5axis-session.json");
+assert.equal(saved.storageMode, "memory");
+assert.equal(store.getState().sessionPersistence.storageMode, "memory");
store.dispatch({ type: "SET_RTCP", enabled: false });
store.dispatch({ type: "STOP" });
@@ -60,7 +62,9 @@ assert.notEqual(store.getState().axisPose.x, beforeSave.axisPose.x);
const restored = await store.restoreSession({ storage });
const afterRestore = store.getState();
assert.equal(restored.path, saved.path);
+assert.equal(restored.storageMode, "memory");
assert.equal(afterRestore.sessionPersistence.status, "restored");
+assert.equal(afterRestore.sessionPersistence.storageMode, "memory");
assert.equal(afterRestore.rtcpState, beforeSave.rtcpState);
assert.equal(afterRestore.kinsType, beforeSave.kinsType);
assert.equal(afterRestore.axisPose.x, beforeSave.axisPose.x);
diff --git a/备忘/备忘1.txt b/备忘/备忘1.txt
index e80b5aa..cee5bb0 100644
--- a/备忘/备忘1.txt
+++ b/备忘/备忘1.txt
@@ -1,40 +1,91 @@
-wasm-port/docs/porting-steps-standalone.md 的 Phase 9/10 继续推进,并直接补上第一批独立 kinematics WASM ABI。
-做全量 kinematics ABI
+需要完善的内容,做个备忘
+按 web-rtcp-5axis-sim-plan/docs/native-task-hal-sync-implementation-steps.md 的边界要求,本次只处理 Web Three.js 可视化层,不改 LinuxCNC task/HAL runtime 语义。
完成内容:
-新增独立 kinematics WASM C ABI:[linuxcnc_kinematics_wasm.c](/home/cnc/桌面/cnc_wams/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_kinematics_wasm.c)
+Three.js 三维显示区改为 toolpath-only:删除画布内地板、网格、坐标轴、机床包络、转台、刀具实体,只保留刀具预览路径线。
-新增构建脚本:[build_kinematics_wasm.sh](/home/cnc/桌面/cnc_wams/wasm-port/tools/build_kinematics_wasm.sh)
+补齐 Three.js 相机交互:
+- 鼠标左键/单指拖动:旋转
+- 鼠标中键、右键或 Shift+拖动:平移
+- 鼠标滚轮:缩放
+- 双指:缩放并平移
+- X/Y/Z/Fit 按钮:预设视角/复位视角
-新增 SDK:[linuxcnc-kinematics.js](/home/cnc/桌面/cnc_wams/wasm-port/runtime/sdk/src/linuxcnc-kinematics.js)
-并从 [index.js](/home/cnc/桌面/cnc_wams/wasm-port/runtime/sdk/src/index.js) 导出
-新增 Node/WASM smoke:[verify_kinematics_wasm.sh](/home/cnc/桌面/cnc_wams/wasm-port/tests/wasm/node/verify_kinematics_wasm.sh)
-[verify_kinematics_wasm.mjs](/home/cnc/桌面/cnc_wams/wasm-port/tests/wasm/node/verify_kinematics_wasm.mjs)
+边界说明:
+这次没有提升 nativeTaskReady/nativeHalSyncReady;它仍属于 Web simulation UI 显示层调整。task/HAL native/WASM 边界仍需继续按执行文档阶段 0-8 推进。
-更新文档:[porting-steps-standalone.md](/home/cnc/桌面/cnc_wams/wasm-port/docs/porting-steps-standalone.md)
-[README.md](/home/cnc/桌面/cnc_wams/wasm-port/runtime/sdk/README.md)
-写入新接续文件:[text39.txt](/home/cnc/桌面/cnc_wams/textbak/text39.txt)
+继续按 web-rtcp-5axis-sim-plan/docs/native-task-hal-sync-implementation-steps.md 边界推进 Three.js 显示层:程序预览和刀具执行过程已显示在 Three.js 区域。
+
+完成内容:
+Three.js 区域现在同时显示:
+- 完整程序预览路径:来自 state.programExecution.motion,也就是 LinuxCNC interpreter/WASM canonical motion events。
+- 已执行路径:优先来自 state.programExecutionTiming.samples,也就是 LinuxCNC TP queue runtime timing samples;没有 samples 时回退到 canonical motion 端点。
+- 当前刀具/TCP 执行标记:来自 state.programRuntimeFeedback.axisPose / task-HAL runtime feedback,并显示刀轴方向线。
+- 快速移动路径辅助层:从 canonical motion 中的 STRAIGHT_TRAVERSE 提取。
+
+保留功能:
+- 鼠标/触控旋转、平移、缩放仍可用。
+- X/Y/Z/Fit 预设视角仍可用。
+- Clear 会同步清空完整预览路径和已执行路径。
+
+实现文件:
+[five-axis-scene.js](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/app/src/visualization/five-axis-scene.js)
+[gmoccapy_shell_smoke.html](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html)
+
+新增可观测状态:
+canvas.dataset.threeSceneMode=program-preview-and-tool-execution
+canvas.dataset.threeProgramPreviewSource=linuxcnc-interpreter-wasm / linuxcnc-machine-file-remap-wasm / fixture-line-playback
+canvas.dataset.threePathPoints=完整程序预览点数
+canvas.dataset.threeExecutedPathPoints=已执行路径点数
+canvas.dataset.threeToolExecutionMarker=true/false
-当前生成的独立 kinematics WASM 产物:
-linuxcnc_xyzac_trt_kinematics.js
-linuxcnc_xyzac_trt_kinematics.wasm
-linuxcnc_xyzbc_trt_kinematics.js
-linuxcnc_xyzbc_trt_kinematics.wasm
验证已通过:
-EMSDK_QUIET=1 source /home/cnc/emsdk/emsdk_env.sh >/dev/null && wasm-port/tests/wasm/node/verify_kinematics_wasm.sh
-输出包含:
-kinematics_wasm_node_smoke=ok
-准确状态现在是:xyzac-trt / xyzbc-trt TRT kinematics 独立 WASM ABI 已完成第一批;但不能说 LinuxCNC 所有逆解算法都已完整移植到 WASM。全量 kinematics family 仍需继续逐个按 native probe -> 独立 WASM module -> SDK smoke 的路径补齐。
-
+npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
+bash web-rtcp-5axis-sim-plan/tests/browser/verify_gmoccapy_shell_browser.sh
+bash web-rtcp-5axis-sim-plan/tests/browser/verify_gmoccapy_dist_browser.sh
+
+边界说明:
+Three.js 不解析 G-code,不手写 LinuxCNC 运动语义;只消费已有 LinuxCNC interpreter canonical motion、TP timing samples、task/HAL runtime feedback。nativeTaskReady/nativeHalSyncReady 的提升仍按执行文档阶段任务另行完成。
+我先确认当前程序执行数据从哪里来,以及 LinuxCNC 示例程序在仓库里如何被加载。目标是让 Three.js 区域既能显示完整程序预览路径,也能显示执行中的刀具位置/已执行轨迹,并尽量复用已有 interpreter/task/HAL 输出,不手写新的 CNC 语义。
文档更新会比较小:把 “下一步要做 Worker/store” 改为 “已接线”,把当前边界标为 Web simulation promoted,同时仍明确 hardware/realtime kernel/external user-M/tool DB 不支持。历史 M18/M13 记录不改。
+公网测试已经跑到一个明确失败点:Save Session 在 http://82.156.24.101:8092 下抛出 OPFS is not available in this browser。这类浏览器存储 API 在公网 HTTP/IP 环境不是安全上下文,很多“保存、恢复、暂存机床文件、完整边界审计”都会受影响;我会继续把其余功能用异常隔离方式跑完,确认还有没有独立问题。
+代码确认了:会话保存和机床文件暂存都硬依赖 OPFS。公网 HTTP/IP 不满足安全上下文,因此这些功能天然不可用;我会先用降级后的脚本继续测试不依赖 OPFS 的操作功能,再给出完整问题清单。
+
+本轮继续处理公网 HTTP/IP 测试失败点:Save Session 在 http://82.156.24.101:8092 下因 OPFS 不可用报错。
+
+完成内容:
+- five-axis session runtime 现在会优先使用 OPFS;如果当前浏览器没有 navigator.storage.getDirectory,则自动使用页面内存会话存储。
+- Save Session / Restore Session 返回并记录 storageMode:
+ - opfs:安全上下文下的 OPFS 持久化;
+ - memory-fallback:公网 HTTP/IP 等无 OPFS 环境下的页面内存后备;
+ - memory:测试显式注入的内存存储。
+- gmoccapy 信息面板 Session 行现在显示 status / storageMode / path,避免把 memory-fallback 误认成 OPFS 持久化。
+- browser smoke 覆盖默认 Save/Restore 按钮路径,确认无 OPFS 环境不会再抛出 “OPFS is not available in this browser.”。
+
+实现文件:
+[five-axis-session.js](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js)
+[store.js](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/app/src/state/store.js)
+[gmoccapy-shell.js](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js)
+[verify_five_axis_session.mjs](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs)
+[gmoccapy_shell_smoke.html](/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html)
+
+验证已通过:
+node web-rtcp-5axis-sim-plan/tests/node/verify_five_axis_session.mjs
+bash web-rtcp-5axis-sim-plan/tests/browser/verify_gmoccapy_shell_browser.sh
+npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
+bash web-rtcp-5axis-sim-plan/tests/browser/verify_gmoccapy_dist_browser.sh
+
+边界说明:
+memory-fallback 只解决公网 HTTP/IP 下“当前页面生命周期内保存/恢复会话”的可用性,不声称提供 OPFS 持久化。机床文件暂存、完整边界审计中仍硬依赖 OPFS 的部分,公网 HTTP/IP 下还需要继续按功能逐项降级或标记 blocked。
+