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。 +