import * as THREE from "../vendor/three/three.module.js"; import { linearUnitsLabel, linearUnitsToMetersFactor, linearValueToMeters, resolveStateLinearUnits, } from "../runtime/linear-units.js"; import { buildVismachModelState } from "../runtime/vismach-model-state.js"; const scenes = new WeakMap(); const CAMERA_PRESETS = { iso: { theta: -0.96, phi: 1.02, radius: 0.72, target: new THREE.Vector3(0, 0, 0) }, x: { theta: 0, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) }, y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) }, z: { theta: 0, phi: 0.001, radius: 0.68, target: new THREE.Vector3(0, 0, 0) }, }; const MAX_TOOLPATH_POINTS = Number.POSITIVE_INFINITY; const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]); const LOCAL_TOOL_AXIS = new THREE.Vector3(0, 0, 1); export function renderFiveAxisScene(canvas, state) { let preview = scenes.get(canvas); if (!preview) { preview = createPreview(canvas); scenes.set(canvas, preview); } if (preview.kind === "fallback") { renderFallbackPreview(preview, state); return; } try { resizeRenderer(preview); updateToolpathPreview(preview, state); preview.renderer.render(preview.scene, preview.camera); } catch (error) { const fallback = createFallbackPreview(canvas, error); scenes.set(canvas, fallback); renderFallbackPreview(fallback, state); return; } const axisReferenceMode = isAxisReferencePreview(state); const pointCount = axisReferenceMode ? Number(state.programAxisPreviewPath?.sampleCount || 0) : geometryPointCount(preview.previewPath.geometry); const executedPointCount = geometryPointCount(preview.executedPath.geometry); exposePreviewDataset(canvas, state, { pointCount, executedPointCount, feedPointCount: geometryPointCount(preview.feedPath.geometry), rapidPointCount: geometryPointCount(preview.rapidPath.geometry), arcPointCount: geometryPointCount(preview.arcPath.geometry), currentSegmentPointCount: geometryPointCount(preview.currentSegmentPath.geometry), sceneObjectCount: countSceneObjects(preview.scene), toolhead: preview.currentToolhead, renderer: "webgl", sceneMode: axisReferenceMode ? "linuxcnc-axis-source-preview" : "program-preview-and-tool-execution", machineReferenceModel: axisReferenceMode ? "linuxcnc-axis-preview-reference" : "webgl-five-axis-reference", cameraControls: preview.controls.enabled, toolExecutionMarker: preview.toolMarker.visible, toolAxisMarker: preview.toolAxis.visible, pathFitBounds: preview.pathFitBoundsReady, pathBounds: axisReferenceMode ? summarizeBounds(computePointBounds(buildProgramPreviewPoints(state))) : computePointBoundsFromGeometryGroups([ preview.previewPath.geometry, preview.executedPath.geometry, preview.currentSegmentPath.geometry, ]), vismachModel: preview.currentVismachModelState, }); } function createPreview(canvas) { try { return createScene(canvas); } catch (error) { return createFallbackPreview(canvas, error); } } function createScene(canvas) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true, }); renderer.setClearColor(0x030405, 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(42, 1, 0.001, 10); camera.up.set(0, 0, 1); const machineModel = createMachineReferenceModel(); const axisReference = createAxisReferencePreviewModel(); scene.add(machineModel.root); scene.add(axisReference.root); const previewPath = createLine(0xffffff, 0.9); const feedPath = createSegmentLine(0x00a8a8, 0.92); const executedPath = createLine(0x1ffff4, 1); const rapidPath = createSegmentLine(0x00a8a8, 0.92); const arcPath = createSegmentLine(0xffffff, 0.95); const currentSegmentPath = createLine(0xff4fd8, 1); const toolMarker = new THREE.Mesh( new THREE.SphereGeometry(0.0065, 18, 12), new THREE.MeshBasicMaterial({ color: 0x1ffff4 }), ); const toolAxis = new THREE.Line( EMPTY_GEOMETRY.clone(), new THREE.LineBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.9 }), ); scene.add(previewPath, feedPath, rapidPath, arcPath, executedPath, currentSegmentPath, toolAxis, toolMarker); const controls = createToolpathCameraControls(canvas, camera, () => { renderer.render(scene, camera); }); const preview = { kind: "webgl", renderer, scene, camera, controls, previewPath, feedPath, executedPath, rapidPath, arcPath, currentSegmentPath, machineModel, axisReference, toolMarker, toolAxis, currentToolhead: new THREE.Vector3(), currentToolGlyphAxis: new THREE.Vector3(0, 0, 1), currentVismachModelState: null, lastSelectedView: null, lastCameraRevision: null, lastFitKey: null, pathFitBoundsReady: false, }; resizeRenderer(preview); resetCamera(preview, "iso"); return preview; } function createFallbackPreview(canvas, error) { return { kind: "fallback", canvas, errorMessage: error instanceof Error ? error.message : String(error), }; } function renderFallbackPreview(preview, state) { const { canvas } = preview; const width = Math.max(canvas.clientWidth, 320); const height = Math.max(canvas.clientHeight, 240); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const previewPoints = buildProgramPreviewPoints(state); const executedPoints = buildExecutedProgramPoints(state, previewPoints); const rapidPoints = buildRapidPreviewPoints(state); const feedPoints = buildTypedPreviewPoints(state, "STRAIGHT_FEED"); const arcPoints = buildTypedPreviewPoints(state, "ARC_FEED"); const currentSegmentPoints = buildCurrentSegmentPoints(state); const pointCount = previewPoints.length; const executedPointCount = executedPoints.length; const toolPosition = executionToolPosition(state, previewPoints); exposePreviewDataset(canvas, state, { pointCount, executedPointCount, sceneObjectCount: 8 + (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", machineReferenceModel: "2d-five-axis-reference", cameraControls: false, toolExecutionMarker: Boolean(toolPosition), toolAxisMarker: Boolean(toolPosition), feedPointCount: feedPoints.length, rapidPointCount: rapidPoints.length, arcPointCount: arcPoints.length, currentSegmentPointCount: currentSegmentPoints.length, pathFitBounds: computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints)) !== null, pathBounds: summarizeBounds(computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints))), vismachModel: buildVismachModelState(state), }); canvas.dataset.threeFallbackReason = preview.errorMessage; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, width, height); ctx.fillStyle = "#030405"; ctx.fillRect(0, 0, width, height); const cx = width * 0.5; const cy = height * 0.53; const scale = Math.min(width / 0.72, height / 0.48); drawFallbackMachineReference(ctx, cx, cy, scale, state); if (pointCount > 0) { ctx.strokeStyle = "#8d95a0"; ctx.lineWidth = 2; ctx.beginPath(); drawFallbackPolyline(ctx, previewPoints, cx, cy, scale); ctx.stroke(); } if (executedPointCount > 0) { ctx.strokeStyle = "#1ffff4"; ctx.lineWidth = 3; ctx.beginPath(); drawFallbackPolyline(ctx, executedPoints, cx, cy, scale); ctx.stroke(); } if (currentSegmentPoints.length > 0) { ctx.strokeStyle = "#ff4fd8"; ctx.lineWidth = 4; ctx.beginPath(); drawFallbackPolyline(ctx, currentSegmentPoints, cx, cy, scale); ctx.stroke(); } 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); } 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.threeSceneUnits = "m"; canvas.dataset.threeLinearUnits = linearUnitsLabel(resolveSceneLinearUnits(state)); canvas.dataset.threeLinearUnitScaleToMeters = String(linearUnitsToMetersFactor(resolveSceneLinearUnits(state))); canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector)); canvas.dataset.threeToolGlyphAxis = JSON.stringify(toRoundedVector(preview.toolGlyphAxis || preview.currentToolGlyphAxis || state.toolAxisVector)); canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose)); canvas.dataset.threeRtcpState = state.rtcpState; canvas.dataset.threeSelectedView = state.preview.selectedView; canvas.dataset.threeFrameApi = state.rtcpFrame.apiName; canvas.dataset.threeRenderer = preview.renderer; canvas.dataset.threeSceneMode = preview.sceneMode; canvas.dataset.threePreviewScope = preview.machineReferenceModel ? "machine-reference-and-toolpath" : "toolpath-only"; canvas.dataset.threeMachineReferenceModel = preview.machineReferenceModel || "none"; canvas.dataset.threeCameraControls = preview.cameraControls ? "orbit-pan-zoom" : "none"; canvas.dataset.threeProgramPreviewSource = previewSourceMode(state); canvas.dataset.threeToolExecutionMarker = preview.toolExecutionMarker ? "true" : "false"; canvas.dataset.threeTcpMarker = preview.toolExecutionMarker ? "sphere" : "hidden"; canvas.dataset.threeToolAxisMarker = preview.toolAxisMarker ? "line" : "hidden"; canvas.dataset.threeToolpathPreviewSource = toolpathPreviewSource(state); canvas.dataset.threeToolExecutionTraceSource = toolExecutionTraceSource(state); canvas.dataset.threePathFitBounds = preview.pathFitBounds ? "ok" : "pending"; canvas.dataset.threePathBoundsMeters = JSON.stringify(preview.pathBounds || null); canvas.dataset.threeCurrentSegmentHighlight = preview.currentSegmentPointCount > 0 ? "ok" : "pending"; canvas.dataset.threeRapidFeedVisualDistinction = preview.rapidPointCount > 0 || preview.feedPointCount > 0 || preview.arcPointCount > 0 ? "ok" : "pending"; canvas.dataset.threeNoGcodeSemanticsGeneration = "ok"; canvas.dataset.threeRapidPathPoints = String(preview.rapidPointCount || 0); canvas.dataset.threeFeedPathPoints = String(preview.feedPointCount || 0); canvas.dataset.threeArcPathPoints = String(preview.arcPointCount || 0); canvas.dataset.threeCurrentSegmentPoints = String(preview.currentSegmentPointCount || 0); canvas.dataset.threeCurrentSegmentType = currentSegmentType(state); canvas.dataset.threeVismachPins = JSON.stringify(preview.vismachModel?.pins || {}); canvas.dataset.threeVismachTransforms = JSON.stringify(preview.vismachModel?.transforms || {}); canvas.dataset.threeVismachPinDrivenModel = preview.vismachModel?.semanticBoundary || "none"; } function createLine(color, opacity) { return new THREE.Line( EMPTY_GEOMETRY.clone(), new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity, }), ); } function createSegmentLine(color, opacity) { return new THREE.LineSegments( EMPTY_GEOMETRY.clone(), new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity, }), ); } function createMachineReferenceModel() { const root = new THREE.Group(); root.name = "five-axis-machine-reference"; const base = new THREE.Mesh( new THREE.BoxGeometry(0.48, 0.32, 0.008), new THREE.MeshBasicMaterial({ color: 0x222930 }), ); base.position.z = -0.016; const tableAxis = new THREE.Group(); tableAxis.name = "vismach-table-x"; const saddleAxis = new THREE.Group(); saddleAxis.name = "vismach-saddle-y"; const spindleAxis = new THREE.Group(); spindleAxis.name = "vismach-spindle-z"; const tiltAxis = new THREE.Group(); tiltAxis.name = "vismach-tilt-b"; const rotaryAxis = new THREE.Group(); rotaryAxis.name = "vismach-rotate-c"; const toolOffsetAxis = new THREE.Group(); toolOffsetAxis.name = "vismach-tool-offset"; const table = new THREE.Mesh( new THREE.BoxGeometry(0.37, 0.235, 0.005), new THREE.MeshBasicMaterial({ color: 0x3a444d, transparent: true, opacity: 0.78 }), ); table.position.z = -0.008; const xAxis = createStaticLine([new THREE.Vector3(-0.22, 0, 0), new THREE.Vector3(0.225, 0, 0)], 0xff4d4d); const yAxis = createStaticLine([new THREE.Vector3(0, -0.155, 0), new THREE.Vector3(0, 0.16, 0)], 0x70df7d); const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.008), new THREE.Vector3(0, 0, 0.175)], 0x5aa7ff); const rotaryA = new THREE.Mesh( new THREE.TorusGeometry(0.088, 0.0018, 8, 72), new THREE.MeshBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.92 }), ); rotaryA.rotation.y = Math.PI / 2; const rotaryC = new THREE.Mesh( new THREE.TorusGeometry(0.11, 0.0016, 8, 72), new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.9 }), ); rotaryC.rotation.x = Math.PI / 2; rotaryC.position.z = 0.004; const toolHolder = new THREE.Group(); const holderBody = new THREE.Mesh( new THREE.CylinderGeometry(0.008, 0.008, 0.042, 18), new THREE.MeshBasicMaterial({ color: 0xf1f5f9 }), ); holderBody.rotation.x = Math.PI / 2; holderBody.position.z = 0.046; const cutter = new THREE.Mesh( new THREE.ConeGeometry(0.006, 0.025, 18), new THREE.MeshBasicMaterial({ color: 0xfff176 }), ); cutter.rotation.x = -Math.PI / 2; cutter.position.z = 0.0125; toolHolder.add(holderBody, cutter); toolOffsetAxis.add(toolHolder); spindleAxis.add(toolOffsetAxis); rotaryAxis.add(table, rotaryC); tiltAxis.add(rotaryA, rotaryAxis); saddleAxis.add(tiltAxis); tableAxis.add(saddleAxis); root.add(base, xAxis, yAxis, zAxis, tableAxis, spindleAxis); return { root, tableAxis, saddleAxis, spindleAxis, tiltAxis, rotaryAxis, toolOffsetAxis, rotaryA, rotaryC, toolHolder, }; } function createAxisReferencePreviewModel() { const root = new THREE.Group(); root.name = "axis-native-preview-reference"; root.visible = false; const zLift = 0.0004; const xAxis = createStaticLine([ new THREE.Vector3(-0.024, 0, zLift), new THREE.Vector3(0.036, 0, zLift), ], 0x00ff00); const yAxis = createStaticLine([ new THREE.Vector3(0, -0.024, zLift), new THREE.Vector3(0, 0.036, zLift), ], 0xff2020); const zAxis = createStaticLine([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0.032), ], 0x3030ff); const dimensions = createDimensionLines(); const labels = [ createTextSprite("X", 0x00ff00, new THREE.Vector3(0.039, 0, zLift), 0.0034), createTextSprite("Y", 0xff2020, new THREE.Vector3(0, 0.039, zLift), 0.0034), createTextSprite("Z", 0x3030ff, new THREE.Vector3(0, 0, 0.032), 0.0034), createTextSprite("60.0", 0xff7070, new THREE.Vector3(0, -0.0355, zLift), 0.0026), createTextSprite("60.0", 0xff7070, new THREE.Vector3(-0.0355, 0, zLift), 0.0026), createTextSprite("30.0", 0xff7070, new THREE.Vector3(-0.028, 0.014, zLift), 0.0026), createTextSprite("30.0", 0xff7070, new THREE.Vector3(-0.014, -0.028, zLift), 0.0026), ]; const tool = new THREE.Group(); tool.name = "axis-native-tool-glyph"; const cone = new THREE.Mesh( new THREE.ConeGeometry(0.0017, 0.0048, 4), new THREE.MeshBasicMaterial({ color: 0xe7eef7 }), ); cone.rotation.x = -Math.PI / 2; cone.position.z = 0.0024; const holder = new THREE.Mesh( new THREE.CylinderGeometry(0.0011, 0.0011, 0.0065, 8), new THREE.MeshBasicMaterial({ color: 0xbfc8d0 }), ); holder.rotation.x = Math.PI / 2; holder.position.z = 0.00805; tool.add(cone, holder); root.add(xAxis, yAxis, zAxis, dimensions, tool, ...labels); return { root, tool, }; } function createDimensionLines() { const group = new THREE.Group(); group.name = "axis-native-preview-dimensions"; const z = 0.0002; const lines = [ [new THREE.Vector3(-0.030, -0.033, z), new THREE.Vector3(0.030, -0.033, z)], [new THREE.Vector3(-0.030, -0.0355, z), new THREE.Vector3(-0.030, -0.0305, z)], [new THREE.Vector3(0.030, -0.0355, z), new THREE.Vector3(0.030, -0.0305, z)], [new THREE.Vector3(-0.033, -0.030, z), new THREE.Vector3(-0.033, 0.030, z)], [new THREE.Vector3(-0.0355, -0.030, z), new THREE.Vector3(-0.0305, -0.030, z)], [new THREE.Vector3(-0.0355, 0.030, z), new THREE.Vector3(-0.0305, 0.030, z)], [new THREE.Vector3(-0.030, 0.030, z), new THREE.Vector3(0, 0.030, z)], [new THREE.Vector3(-0.030, -0.030, z), new THREE.Vector3(-0.030, 0, z)], ]; for (const [start, end] of lines) { group.add(createStaticLine([start, end], 0xff3030)); } return group; } function createTextSprite(text, color, position, height) { const canvas = document.createElement("canvas"); canvas.width = 192; canvas.height = 64; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = "32px Courier New, monospace"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = `#${color.toString(16).padStart(6, "0")}`; ctx.fillText(text, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, }); const sprite = new THREE.Sprite(material); sprite.position.copy(position); sprite.scale.set(height * 3, height, 1); return sprite; } function createStaticLine(points, color) { return new THREE.Line( new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 }), ); } function updateToolpathPreview(preview, state) { const axisReferenceMode = isAxisReferencePreview(state); const previewPoints = buildProgramPreviewPoints(state); const executedPoints = buildExecutedProgramPoints(state, previewPoints); const rapidPoints = buildRapidPreviewPoints(state); const feedPoints = buildTypedPreviewPoints(state, "STRAIGHT_FEED"); const arcPoints = buildTypedPreviewPoints(state, "ARC_FEED"); const currentSegmentPoints = buildCurrentSegmentPoints(state); const toolPosition = executionToolPosition(state, previewPoints); const fitPoints = collectFitPoints(previewPoints, executedPoints, currentSegmentPoints, toolPosition); const fitKey = [ previewPoints.length, executedPoints.length, currentSegmentPoints.length, previewSourceMode(state), state.programExecutionMotionIndex || 0, state.programExecutionSampleIndex || 0, ].join(":"); if (axisReferenceMode) { updateLineGeometry(preview.previewPath, []); updateLineGeometry(preview.feedPath, []); updateLineGeometry(preview.executedPath, []); updateLineSegmentsGeometry(preview.rapidPath, rapidPoints); updateLineSegmentsGeometry(preview.arcPath, arcPoints); updateLineGeometry(preview.currentSegmentPath, []); updateToolExecutionMarker(preview, state, null); } else { updateLineGeometry(preview.previewPath, previewPoints); updateLineGeometry(preview.feedPath, feedPoints); updateLineGeometry(preview.executedPath, executedPoints); updateLineSegmentsGeometry(preview.rapidPath, rapidPoints); updateLineSegmentsGeometry(preview.arcPath, arcPoints); updateLineGeometry(preview.currentSegmentPath, currentSegmentPoints); updateToolExecutionMarker(preview, state, toolPosition); } updateMachineReferenceModel(preview, state, toolPosition, axisReferenceMode); const cameraRevision = state.preview.cameraRevision ?? 0; if ( preview.lastSelectedView !== state.preview.selectedView || preview.lastCameraRevision !== cameraRevision || preview.lastFitKey !== fitKey ) { resetCamera(preview, state.preview.selectedView, fitPoints, axisReferenceMode); preview.lastSelectedView = state.preview.selectedView; preview.lastCameraRevision = cameraRevision; preview.lastFitKey = fitKey; } 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.07)), ]); } function updateMachineReferenceModel(preview, state, toolPosition, axisReferenceMode = false) { const model = preview.machineModel; if (!model) return; model.root.visible = !axisReferenceMode; if (preview.axisReference) { preview.axisReference.root.visible = axisReferenceMode; if (axisReferenceMode) { const referenceToolPosition = new THREE.Vector3(0, 0, 0.006); const toolVector = toToolVector(state.toolAxisVector); preview.axisReference.tool.position.copy(referenceToolPosition); alignToolGlyphToAxis(preview.axisReference.tool, toolVector); preview.currentToolGlyphAxis.copy(toolVector); } } if (axisReferenceMode) { preview.currentVismachModelState = null; return; } const vismach = buildVismachModelState(state); preview.currentVismachModelState = summarizeVismachModelStateForDataset(vismach); const toMeters = (value) => linearValueToMeters(value, vismach.linearUnits); model.tableAxis.position.x = toMeters(vismach.transforms.table.translate.x); model.saddleAxis.position.y = toMeters(vismach.transforms.saddle.translate.y); model.spindleAxis.position.z = toMeters(vismach.transforms.spindle.translate.z); model.tiltAxis.rotation.y = degreesToRadians(vismach.transforms.tilt.rotateDeg.y); model.rotaryAxis.rotation.z = degreesToRadians(vismach.transforms.rotary.rotateDeg.z); model.toolOffsetAxis.position.set( toMeters(vismach.transforms.tool.translate.x), 0, toMeters(vismach.transforms.tool.translate.z), ); model.rotaryA.rotation.y = Math.PI / 2; model.rotaryC.rotation.x = Math.PI / 2; const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose, state); const toolVector = toToolVector(state.toolAxisVector); model.toolHolder.position.copy(tcpPosition); alignToolGlyphToAxis(model.toolHolder, toolVector); preview.currentToolGlyphAxis.copy(toolVector); } function alignToolGlyphToAxis(object, toolVector) { const axis = toolVector.clone().normalize(); if (!Number.isFinite(axis.x) || !Number.isFinite(axis.y) || !Number.isFinite(axis.z) || axis.lengthSq() === 0) { axis.copy(LOCAL_TOOL_AXIS); } object.quaternion.setFromUnitVectors(LOCAL_TOOL_AXIS, axis); } 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 updateLineSegmentsGeometry(line, segmentPoints) { line.visible = segmentPoints.length > 0; line.geometry.dispose(); line.geometry = segmentPoints.length > 0 ? new THREE.BufferGeometry().setFromPoints(segmentPoints) : EMPTY_GEOMETRY.clone(); } function geometryPointCount(geometry) { return geometry?.getAttribute("position")?.count || 0; } function buildProgramPreviewPoints(state) { const previewSamples = state.programAxisPreviewPath?.samples; if (Array.isArray(previewSamples) && previewSamples.length > 0 && state.preview.pathPoints !== 0) { return limitPoints(previewSamples.map((sample) => vectorFromAxes(sample.tcp || sample.joint, state, "mm"))); } const motion = state.programExecution?.motion; if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) { return limitPoints(motion.map((event) => vectorFromAxes(event.axes, state, event.linearUnits))); } const pointCount = normalizePathPointCount(state.preview.pathPoints); if (pointCount === 0) return []; return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose, state)); } function buildExecutedProgramPoints(state, previewPoints) { if (state.preview.pathPoints === 0 || previewPoints.length === 0) return []; const previewSamples = state.programAxisPreviewPath?.samples; if (Array.isArray(previewSamples) && previewSamples.length > 0) { const end = clamp(Math.round(Number(state.programExecutionSampleIndex || 0)), 0, previewSamples.length - 1); return previewPoints.slice(0, end + 1); } 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, state, sample.linearUnits))); } const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1); return previewPoints.slice(0, motionIndex + 1); } function buildRapidPreviewPoints(state) { return buildTypedPreviewPoints(state, "STRAIGHT_TRAVERSE"); } function buildTypedPreviewPoints(state, type) { const previewSamples = state.programAxisPreviewPath?.samples; if (Array.isArray(previewSamples) && previewSamples.length > 0 && state.preview.pathPoints !== 0) { const expectedMotionType = type === "STRAIGHT_TRAVERSE" ? "rapid" : type === "ARC_FEED" ? "arc" : "feed"; return limitPoints(buildAxisSampleLineSegments(previewSamples, state, expectedMotionType)); } const motion = state.programExecution?.motion; if (!Array.isArray(motion) || state.preview.pathPoints === 0) return []; return limitPoints( motion .filter((event) => event.type === type) .flatMap((event, index, events) => { const previous = events[Math.max(index - 1, 0)]; return [ vectorFromAxes(previous.axes || event.axes, state, previous.linearUnits || event.linearUnits), vectorFromAxes(event.axes, state, event.linearUnits), ]; }), ); } function buildAxisSampleLineSegments(samples, state, motionType) { const points = []; for (let index = 1; index < samples.length; index += 1) { const previous = samples[index - 1]; const current = samples[index]; if (previous.motionType !== motionType || current.motionType !== motionType) continue; if (current.line !== previous.line && motionType === "arc") continue; points.push( vectorFromAxes(previous.tcp || previous.joint, state, "mm"), vectorFromAxes(current.tcp || current.joint, state, "mm"), ); } return points; } function buildCurrentSegmentPoints(state) { if (state.preview.pathPoints === 0) return []; const previewSamples = state.programAxisPreviewPath?.samples; if (Array.isArray(previewSamples) && previewSamples.length > 0) { const sampleIndex = clamp(Math.round(Number(state.programExecutionSampleIndex || 0)), 0, previewSamples.length - 1); const current = previewSamples[sampleIndex]; const previous = previewSamples[Math.max(sampleIndex - 1, 0)]; return [previous, current] .filter(Boolean) .map((sample) => vectorFromAxes(sample.tcp || sample.joint, state, "mm")); } const motion = state.programExecution?.motion; if (!Array.isArray(motion) || motion.length === 0) return []; const motionIndex = clampMotionIndex(state, currentMotionIndex(state)); const current = motion[motionIndex]; const previous = motion[Math.max(motionIndex - 1, 0)]; if (!current) return []; const start = motionIndex === 0 ? vectorFromAxes(previous?.axes || current.axes, state, previous?.linearUnits || current.linearUnits) : vectorFromAxes(previous.axes, state, previous.linearUnits); const end = vectorFromAxes(current.axes, state, current.linearUnits); return start.distanceTo(end) > 0 ? [start, end] : [end]; } function buildFixturePreviewPoints(pointCount, tcpPosition) { const points = []; for (let index = 0; index < pointCount; index += 1) { const t = pointCount === 1 ? 0 : index / (pointCount - 1); const x = -0.085 + t * 0.17; const y = Math.sin(t * Math.PI * 13) * 0.012; const z = 0.012 + Math.sin(t * Math.PI * 2) * 0.018; points.push(new THREE.Vector3(x, y, z)); } if (points.length > 0 && tcpPosition) { points[points.length - 1] = tcpPosition.clone(); } return points; } function executionToolPosition(state, previewPoints) { const uiTcp = state.programUiExecution?.tcp; if (uiTcp && hasLinearAxes(uiTcp)) { return vectorFromAxes(uiTcp, state, state.programRuntimeFeedback?.linearUnits || "mm"); } const feedbackTcp = state.programRuntimeFeedback?.tcp; if (feedbackTcp && hasLinearAxes(feedbackTcp)) { return vectorFromAxes(feedbackTcp, state, state.programRuntimeFeedback?.linearUnits || "mm"); } const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback; if (feedbackAxes && hasLinearAxes(feedbackAxes)) { return vectorFromAxes(feedbackAxes, state, state.programRuntimeFeedback?.linearUnits); } if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose, state); return previewPoints.at(-1) || null; } export function axesToSceneMeters(axes = {}, state = {}, linearUnits = null) { const units = linearUnits || axes.linearUnits || resolveSceneLinearUnits(state); return { x: linearValueToMeters(axes.x, units), y: linearValueToMeters(axes.y, units), z: linearValueToMeters(axes.z, units), }; } function vectorFromAxes(axes = {}, state = {}, linearUnits = null) { const point = axesToSceneMeters(axes, state, linearUnits); return new THREE.Vector3( point.x, point.y, point.z, ); } function toPreviewVector(pose = {}, state = {}) { const point = axesToSceneMeters(pose, state); return new THREE.Vector3( point.x, point.y, point.z, ); } function resolveSceneLinearUnits(state) { return resolveStateLinearUnits(state); } 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 toolpathPreviewSource(state) { if (isAxisReferencePreview(state)) { return "axis_preview_expanded_ngcgui_subroutines"; } if (state.programExecution?.sourceMode === "linuxcnc-interpreter-wasm") { return "linuxcnc_interpreter_canonical_motion"; } if (state.programExecution?.sourceMode === "linuxcnc-machine-file-remap-wasm") { return "linuxcnc_machine_file_remap_canonical_motion"; } return "fixture_line_playback_not_promoted"; } function isAxisReferencePreview(state) { return state.programAxisPreviewPath?.source === "web-axis-preview-expanded-ngcgui-subroutines" || state.programAxisPreviewPath?.source === "web-axis-source-execution-expanded-ngcgui-subroutines" || Boolean(state.programAxisPreviewPath?.gcodeExecutionProcess); } function toolExecutionTraceSource(state) { if (Array.isArray(state.programExecutionTiming?.samples) && state.programExecutionTiming.samples.length > 0) { return "linuxcnc_tp_samples_or_task_motion_hal_feedback"; } if (state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm") { return "linuxcnc_tp_samples_or_task_motion_hal_feedback"; } return "fixture_line_playback_not_promoted"; } function currentSegmentType(state) { const motion = state.programExecution?.motion; if (!Array.isArray(motion) || motion.length === 0) return "-"; return motion[clampMotionIndex(state, currentMotionIndex(state))]?.type || "-"; } function currentMotionIndex(state) { const sample = state.programExecutionTiming?.samples?.[Number(state.programExecutionSampleIndex || 0)]; if (Number.isFinite(Number(sample?.motionIndex))) return Number(sample.motionIndex); return Number(state.programExecutionMotionIndex || 0); } function clampMotionIndex(state, index) { const count = state.programExecution?.motion?.length || 0; if (count <= 0) return 0; return clamp(Math.round(Number(index) || 0), 0, count - 1); } function collectFitPoints(...groups) { return groups.flatMap((group) => { if (!group) return []; if (Array.isArray(group)) return group.filter(Boolean); return [group]; }); } function computePointBounds(points) { const valid = points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z)); if (valid.length === 0) return null; const box = new THREE.Box3().setFromPoints(valid); if (box.isEmpty()) return null; return box; } function computePointBoundsFromGeometryGroups(geometries) { const points = []; for (const geometry of geometries) { const position = geometry?.getAttribute("position"); if (!position) continue; for (let index = 0; index < position.count; index += 1) { points.push(new THREE.Vector3( position.getX(index), position.getY(index), position.getZ(index), )); } } return summarizeBounds(computePointBounds(points)); } function summarizeBounds(bounds) { if (!bounds) return null; const center = new THREE.Vector3(); const size = new THREE.Vector3(); bounds.getCenter(center); bounds.getSize(size); return { center: toRoundedVector(center), size: toRoundedVector(size), maxSpan: Number(Math.max(size.x, size.y, size.z).toFixed(6)), }; } function summarizeVismachModelStateForDataset(vismach) { const summarizeVector = (vector = {}) => ({ x: round(vector.x), y: round(vector.y), z: round(vector.z), }); return { ...vismach, pins: Object.fromEntries(Object.entries(vismach.pins).map(([key, value]) => [key, round(value)])), transforms: { table: { ...vismach.transforms.table, translate: summarizeVector(vismach.transforms.table.translate) }, saddle: { ...vismach.transforms.saddle, translate: summarizeVector(vismach.transforms.saddle.translate) }, spindle: { ...vismach.transforms.spindle, translate: summarizeVector(vismach.transforms.spindle.translate) }, tilt: { ...vismach.transforms.tilt }, rotary: { ...vismach.transforms.rotary }, tool: { ...vismach.transforms.tool, translate: summarizeVector(vismach.transforms.tool.translate) }, }, }; } 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 drawFallbackMachineReference(ctx, cx, cy, scale, state) { const tableWidth = 0.48 * scale; const tableHeight = 0.32 * scale; ctx.fillStyle = "#20272e"; ctx.strokeStyle = "#56616b"; ctx.lineWidth = 2; ctx.fillRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight); ctx.strokeRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight); drawFallbackAxis(ctx, cx - 0.225 * scale, cy, cx + 0.225 * scale, cy, "#ff4d4d"); drawFallbackAxis(ctx, cx, cy + 0.155 * scale, cx, cy - 0.16 * scale, "#70df7d"); drawFallbackAxis(ctx, cx, cy + 0.02 * scale, cx, cy - 0.115 * scale, "#5aa7ff"); ctx.strokeStyle = "#1ffff4"; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(cx, cy, 0.092 * scale, 0.042 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = "#ffd166"; ctx.beginPath(); ctx.arc(cx, cy, 0.07 * scale, 0, Math.PI * 2); ctx.stroke(); const tcp = executionToolPosition(state, []); if (tcp) { const toolX = cx + tcp.x * scale; const toolY = cy - tcp.y * scale; ctx.strokeStyle = "#f1f5f9"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(toolX, toolY - 0.038 * scale); ctx.lineTo(toolX, toolY - 0.008 * scale); ctx.stroke(); ctx.fillStyle = "#1ffff4"; ctx.beginPath(); ctx.arc(toolX, toolY, 0.007 * scale, 0, Math.PI * 2); ctx.fill(); } } function drawFallbackAxis(ctx, x1, y1, x2, y2, color) { ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } 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, 0.06, 4); 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), 0.06, 4); 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, fitPoints = [], axisReferenceMode = false) { 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); preview.pathFitBoundsReady = axisReferenceMode ? applyAxisReferenceCamera(preview.controls) : applyFitBounds(preview.controls, selectedView, fitPoints); applyCameraControls(preview.controls); } function applyAxisReferenceCamera(controls) { controls.theta = -0.48; controls.phi = 0.82; controls.radius = 0.125; controls.target.set(0.001, -0.002, 0.006); return true; } function applyFitBounds(controls, selectedView, fitPoints) { const bounds = computePointBounds(fitPoints); if (!bounds) return false; const center = new THREE.Vector3(); const size = new THREE.Vector3(); bounds.getCenter(center); bounds.getSize(size); controls.target.copy(center); const maxSpan = Math.max(size.x, size.y, size.z, 0.08); const fitRadius = clamp(maxSpan * 1.8, 0.22, 4); controls.radius = selectedView === "z" ? Math.max(fitRadius, 0.42) : fitRadius; return true; } 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) { const canvas = preview.renderer.domElement; const width = Math.max(canvas.clientWidth, 320); const height = Math.max(canvas.clientHeight, 240); if (canvas.width !== width || canvas.height !== height) { preview.renderer.setSize(width, height, false); } preview.camera.aspect = width / height; preview.camera.updateProjectionMatrix(); } function countSceneObjects(object) { let count = 1; for (const child of object.children) { count += countSceneObjects(child); } return count; } function toRoundedVector(vector) { return { x: round(vector.x), y: round(vector.y), z: round(vector.z), }; } function toRoundedPose(pose) { return { x: round(pose.x), y: round(pose.y), z: round(pose.z), a: round(pose.a), b: round(pose.b), c: round(pose.c), }; } function round(value) { return Math.round(Number(value) * 1000) / 1000; } function degreesToRadians(value) { return (Number(value) || 0) * Math.PI / 180; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); }