From b3e366e8134cba784c7b55975f1961fb9b003c9e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 8 Nov 2026 18:08:12 +0000 Subject: [PATCH] Remove gcode-preview dependency and related code - Deleted the gcode-preview package from package.json and pnpm-lock.yaml. - Removed references to gcode-preview in vite.config.js. - Introduced new GCodePreview component and related files for G-code visualization. - Added GCodeViewer components including Bed, HotEnd, and SegmentMesh for rendering. - Implemented color modes and feature types for enhanced G-code rendering. - Added GCodeLegend for displaying feature information and toggling visibility. - Established GCodeScene for managing the 3D rendering context and controls. --- assets/stylesheets/App.css | 8 + package.json | 1 - pnpm-lock.yaml | 16 - .../Components/GCodePreview/GCodePreview.jsx | 825 +++++++++++ .../GCodePreview/GCodeViewer/Bed.js | 164 ++ .../GCodeViewer/ExtrusionMaterial.js | 241 +++ .../GCodePreview/GCodeViewer/GCodeLegend.jsx | 220 +++ .../GCodePreview/GCodeViewer/GCodeScene.js | 169 +++ .../GCodePreview/GCodeViewer/HotEnd.js | 106 ++ .../GCodePreview/GCodeViewer/SegmentMesh.js | 311 ++++ .../GCodePreview/GCodeViewer/colorModes.js | 200 +++ .../GCodePreview/GCodeViewer/featureTypes.js | 142 ++ .../GCodePreview/GCodeViewer/parser.worker.js | 1313 +++++++++++++++++ .../GCodePreview/GCodeViewer/planner.js | 169 +++ .../GCodePreview/GCodeViewer/segmentStore.js | 179 +++ .../GCodePreview/GCodeViewer/tracking.js | 102 ++ .../GCodeViewer/useGCodeLoader.js | 143 ++ .../Dashboard/common/FilePreview.jsx | 10 +- .../Dashboard/common/GCodePreview.jsx | 526 ------- .../Dashboard/common/GCodeViewer.jsx | 1 + .../common/PrinterGCodeFilePanel.jsx | 32 +- vite.config.js | 5 - 22 files changed, 4322 insertions(+), 561 deletions(-) create mode 100644 src/components/Components/GCodePreview/GCodePreview.jsx create mode 100644 src/components/Components/GCodePreview/GCodeViewer/Bed.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/ExtrusionMaterial.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/GCodeLegend.jsx create mode 100644 src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/HotEnd.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/SegmentMesh.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/colorModes.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/featureTypes.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/parser.worker.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/planner.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/segmentStore.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/tracking.js create mode 100644 src/components/Components/GCodePreview/GCodeViewer/useGCodeLoader.js delete mode 100644 src/components/Dashboard/common/GCodePreview.jsx create mode 100644 src/components/Dashboard/common/GCodeViewer.jsx diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 511070df..ac27a02f 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -2600,6 +2600,14 @@ body.objectKanbanColumnResizing * { z-index: 2; } +.previewStatsOverlay { + position: absolute; + left: 12px; + bottom: 12px; + z-index: 2; + pointer-events: none; +} + .previewZoomCard { background-color: color-mix( in srgb, diff --git a/package.json b/package.json index f4f5c05d..cf6d1eb3 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", "electron-store": "^11.0.2", - "gcode-preview": "3.0.0-alpha.6", "json-schema-traverse": "^1.0.0", "keycloak-js": "^26.2.0", "lodash": "^4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc7ed5ac..11b1c883 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,6 @@ importers: electron-store: specifier: ^11.0.2 version: 11.0.2 - gcode-preview: - specifier: 3.0.0-alpha.6 - version: 3.0.0-alpha.6 json-schema-traverse: specifier: ^1.0.0 version: 1.0.0 @@ -3371,9 +3368,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gcode-preview@3.0.0-alpha.6: - resolution: {integrity: sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ==} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3807,9 +3801,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lil-gui@0.20.0: - resolution: {integrity: sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -9273,11 +9264,6 @@ snapshots: functions-have-names@1.2.3: {} - gcode-preview@3.0.0-alpha.6: - dependencies: - lil-gui: 0.20.0 - three: 0.179.1 - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -9745,8 +9731,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lil-gui@0.20.0: {} - lines-and-columns@1.2.4: {} linkify-it@5.0.0: diff --git a/src/components/Components/GCodePreview/GCodePreview.jsx b/src/components/Components/GCodePreview/GCodePreview.jsx new file mode 100644 index 00000000..75e409c8 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodePreview.jsx @@ -0,0 +1,825 @@ +import PropTypes from 'prop-types' +import { Button, Card, Flex, Select, Slider, Typography } from 'antd' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as THREE from 'three' +import PlusIcon from '../../Icons/PlusIcon.jsx' +import MinusIcon from '../../Icons/MinusIcon.jsx' +import PanIcon from '../../Icons/PanIcon.jsx' +import PanFilledIcon from '../../Icons/PanFilledIcon.jsx' +import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx' +import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx' +import KeyboardShortcut from '../../Dashboard/common/KeyboardShortcut.jsx' +import ProgressDisplay from '../../Dashboard/common/ProgressDisplay' +import usePinchZoom, { + clampPreviewScale, + MAX_PREVIEW_SCALE, + MIN_PREVIEW_SCALE +} from '../../Dashboard/hooks/usePinchZoom.js' +import { useThemeContext } from '../../Dashboard/context/ThemeContext' +import GCodeScene from './GCodeViewer/GCodeScene.js' +import useGCodeLoader from './GCodeViewer/useGCodeLoader.js' +import GCodeLegend from './GCodeViewer/GCodeLegend.jsx' +import { + COLOR_MODES, + COLOR_MODE_BY_VALUE, + TOOL_PALETTE, + formatDuration +} from './GCodeViewer/colorModes.js' +import { ALL_TYPES_MASK, FEATURE } from './GCodeViewer/featureTypes.js' +import { + fileOffsetForPrinter, + layerIndexForSegment, + pointOnSegment, + refineSegmentByPosition +} from './GCodeViewer/tracking.js' + +const { Text } = Typography + +// Ranges used for scalar colouring until the worker reports real statistics. +const DEFAULT_RANGES = { + height: { min: 0.05, max: 0.4 }, + width: { min: 0.2, max: 1 }, + feed: { min: 10, max: 300 }, + actualSpeed: { min: 10, max: 300 }, + accel: { min: 500, max: 10000 }, + fan: { min: 0, max: 100 }, + temp: { min: 150, max: 300 }, + flow: { min: 0, max: 30 }, + actualFlow: { min: 0, max: 30 }, + layerTime: { min: 0, max: 600 } +} + +const COLOR_MODE_OPTIONS = COLOR_MODES.map((mode) => ({ + value: mode.value, + label: mode.label +})) + +const SCRUB_SPEEDS = [1, 2, 5, 10, 20].map((speed) => ({ + value: speed, + label: `${speed}x` +})) + +function getCameraDistance(scene) { + const distance = scene.camera.position.distanceTo(scene.controls.target) + return Number.isFinite(distance) && distance > 0 ? distance : null +} + +function getCameraScale(scene, baseDistance) { + const distance = getCameraDistance(scene) + return distance == null ? null : clampPreviewScale(baseDistance / distance) +} + +function applyCameraScale(scene, baseDistance, scale) { + const { camera, controls } = scene + const distance = getCameraDistance(scene) + if (distance == null) return + const nextDistance = baseDistance / scale + if (Math.abs(nextDistance - distance) <= distance * 1e-4) return + const offset = camera.position.clone().sub(controls.target) + camera.position + .copy(controls.target) + .addScaledVector(offset, nextDistance / distance) + controls.update() +} + +function isFinitePosition(position) { + return ( + Array.isArray(position) && + position.length >= 3 && + Number.isFinite(position[0]) && + Number.isFinite(position[1]) && + Number.isFinite(position[2]) + ) +} + +function GCodePreview(props) { + const { + src, + startLayer, + endLayer, + style = {}, + showFollowCurrentSubJob = false, + gcodePosition = null, + toolPosition = null + } = props + const { isDarkMode } = useThemeContext() + + const canvasRef = useRef(null) + const paneRef = useRef(null) + const sceneRef = useRef(null) + const baseDistanceRef = useRef(1) + const applyingScaleRef = useRef(false) + const userMovedRef = useRef(false) + + const [scene, setScene] = useState(null) + const [previewScale, setPreviewScale] = useState(1) + const [panMode, setPanMode] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [renderMode, setRenderMode] = useState('extrusion') + const [showTravel, setShowTravel] = useState(false) + const [colorMode, setColorMode] = useState('feature') + const [hiddenTypes, setHiddenTypes] = useState(() => new Set()) + const [rangeOverrides, setRangeOverrides] = useState({}) + const [layerRange, setLayerRange] = useState(null) + const [followSelection, setFollowSelection] = useState('follow') + const [scrubSegment, setScrubSegment] = useState(null) + const [scrubPlaying, setScrubPlaying] = useState(false) + const [scrubSpeed, setScrubSpeed] = useState(1) + + const loader = useGCodeLoader(src) + const { model, revision } = loader + const ready = loader.status === 'ready' + const layers = useMemo( + () => model?.layers || [], + // eslint-disable-next-line react-hooks/exhaustive-deps + [model, revision] + ) + const layerCount = layers.length + // Segment count is read through the revision so React re-evaluates it as + // chunks stream in. + const segmentCount = useMemo( + () => (model ? model.store.count : 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [model, revision] + ) + + const mode = COLOR_MODE_BY_VALUE.get(colorMode) || COLOR_MODES[0] + const tracking = + showFollowCurrentSubJob && + followSelection === 'follow' && + gcodePosition != null && + gcodePosition.isActive !== false + + // ---- scene lifecycle -------------------------------------------------- + + const resizePreview = useCallback(() => { + sceneRef.current?.resize() + }, []) + + const captureBaseDistance = useCallback((instance) => { + const base = getCameraDistance(instance) || 1 + baseDistanceRef.current = base + instance.controls.minDistance = base / MAX_PREVIEW_SCALE + instance.controls.maxDistance = base / MIN_PREVIEW_SCALE + instance.camera.far = Math.max( + instance.camera.far, + (base / MIN_PREVIEW_SCALE) * 2 + ) + instance.camera.updateProjectionMatrix() + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return undefined + const instance = new GCodeScene(canvas) + sceneRef.current = instance + captureBaseDistance(instance) + const { controls } = instance + + const handleControlsChange = () => { + if (applyingScaleRef.current) return + const scale = getCameraScale(instance, baseDistanceRef.current) + if (scale == null) return + setPreviewScale((prev) => (Math.abs(prev - scale) < 0.001 ? prev : scale)) + } + const handleControlsStart = () => { + userMovedRef.current = true + setIsDragging(true) + } + const handleControlsEnd = () => setIsDragging(false) + controls.addEventListener('change', handleControlsChange) + controls.addEventListener('start', handleControlsStart) + controls.addEventListener('end', handleControlsEnd) + + const pane = paneRef.current + const resizeObserver = + pane && typeof ResizeObserver === 'function' + ? new ResizeObserver(resizePreview) + : null + resizeObserver?.observe(pane) + window.addEventListener('resize', resizePreview) + instance.resize() + setScene(instance) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', resizePreview) + controls.removeEventListener('change', handleControlsChange) + controls.removeEventListener('start', handleControlsStart) + controls.removeEventListener('end', handleControlsEnd) + setScene(null) + setIsDragging(false) + instance.dispose() + if (sceneRef.current === instance) sceneRef.current = null + } + }, [captureBaseDistance, resizePreview]) + + useEffect(() => { + if (!scene) return + scene.setTheme({ + background: isDarkMode ? '#1f1f1f' : '#ffffff', + grid: isDarkMode ? '#7a7a7a' : '#9e9e9e' + }) + }, [scene, isDarkMode]) + + // ---- model binding ---------------------------------------------------- + + useEffect(() => { + if (!scene) return + scene.setStore(model ? model.store : null) + userMovedRef.current = false + setLayerRange(null) + setScrubSegment(null) + setScrubPlaying(false) + setRangeOverrides({}) + }, [scene, model]) + + useEffect(() => { + if (!scene || !model) return + scene.syncStore() + if (!model.done) return + const changed = scene.setBuildVolume( + model.metadata?.buildVolume, + model.bounds + ) + if (changed && !userMovedRef.current) { + scene.fitToVolume(scene.buildVolume) + captureBaseDistance(scene) + setPreviewScale(1) + } + }, [scene, model, revision, captureBaseDistance]) + + // Initial layer window from props, applied once the layer list is known. + useEffect(() => { + if (!model?.done || layerCount === 0) return + if (typeof endLayer !== 'number' && typeof startLayer !== 'number') return + setLayerRange((prev) => { + if (prev) return prev + const lo = Math.max(1, (startLayer || 0) + 1) + const hi = Math.min( + layerCount, + typeof endLayer === 'number' ? endLayer : layerCount + ) + return [Math.min(lo, hi), hi] + }) + }, [model, layerCount, startLayer, endLayer]) + + // ---- uniform driven options ------------------------------------------- + + const autoRange = mode.stat ? model?.stats?.[mode.stat] || null : null + const activeRange = rangeOverrides[colorMode] || autoRange || null + const displayRange = + activeRange || (mode.stat ? DEFAULT_RANGES[mode.stat] : null) + + useEffect(() => { + if (!scene) return + const segments = scene.segments + segments.setColorMode(mode.shaderMode, Boolean(mode.logScale)) + if (mode.stat) { + const range = activeRange || DEFAULT_RANGES[mode.stat] + const min = range.min + const max = range.max > min ? range.max : min + 1 + segments.setRange(min, max) + } + if (mode.kind === 'tool') { + const fileColors = model?.metadata?.filamentColors || [] + segments.setToolColors(mode.useFilamentColors ? fileColors : TOOL_PALETTE) + } + scene.requestRender() + }, [scene, mode, activeRange, model, revision]) + + useEffect(() => { + if (!scene) return + let mask = ALL_TYPES_MASK & ~1 + for (const type of hiddenTypes) mask &= ~(1 << type) + if (!showTravel) { + mask &= ~(1 << FEATURE.TRAVEL) + mask &= ~(1 << FEATURE.WIPE) + } + scene.segments.setRenderMode(renderMode) + scene.segments.setVisibleMask(mask) + scene.requestRender() + }, [scene, hiddenTypes, showTravel, renderMode]) + + useEffect(() => { + if (!scene) return + if (layerRange && layerCount > 0) { + scene.segments.setLayerRange(layerRange[0] - 1, layerRange[1] - 1) + } else { + scene.segments.setLayerRange(0, 65535) + } + scene.requestRender() + }, [scene, layerRange, layerCount]) + + // ---- progress: live tracking or scrubbing ------------------------------ + + useEffect(() => { + if (!scene || !model) return + const store = model.store + const segments = scene.segments + + if (tracking) { + const offset = fileOffsetForPrinter( + gcodePosition.filePosition, + gcodePosition.fileSize, + model.byteLength || model.size + ) + const fileSegment = store.segmentAtByteOffset(offset) + const live = isFinitePosition(toolPosition) + ? toolPosition + : isFinitePosition(gcodePosition.position) + ? gcodePosition.position + : null + if (fileSegment < 0) { + segments.setProgress(0, 1) + } else { + const refined = refineSegmentByPosition(store, fileSegment, live) + segments.setProgress(refined.segment + 1, refined.t) + const point = live || pointOnSegment(store, refined.segment, refined.t) + if (point) scene.setToolPosition(point[0], point[1], point[2]) + } + if (fileSegment < 0 && live) { + scene.setToolPosition(live[0], live[1], live[2]) + } + scene.setHotEndVisible(live != null || fileSegment >= 0) + } else if (scrubSegment != null && ready) { + const end = Math.max(0, Math.min(store.count, Math.round(scrubSegment))) + segments.setProgress(end, 1) + if (end > 0) { + const point = pointOnSegment(store, end - 1, 1) + if (point) scene.setToolPosition(point[0], point[1], point[2]) + scene.setHotEndVisible(true) + } else { + scene.setHotEndVisible(false) + } + } else { + segments.showAll() + scene.setHotEndVisible(false) + } + scene.requestRender() + }, [ + scene, + model, + revision, + ready, + tracking, + gcodePosition, + toolPosition, + scrubSegment + ]) + + const scrubRef = useRef(scrubSegment) + scrubRef.current = scrubSegment + + useEffect(() => { + if (!scrubPlaying || !ready || segmentCount === 0) return undefined + let frame = 0 + let last = performance.now() + const rate = Math.max(300, segmentCount / 90) * scrubSpeed + const tick = (now) => { + const dt = Math.min(0.1, (now - last) / 1000) + last = now + const current = scrubRef.current == null ? 0 : scrubRef.current + const next = current + rate * dt + if (next >= segmentCount) { + setScrubSegment(segmentCount) + setScrubPlaying(false) + return + } + setScrubSegment(next) + frame = requestAnimationFrame(tick) + } + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [scrubPlaying, ready, segmentCount, scrubSpeed]) + + useEffect(() => { + if (tracking) setScrubPlaying(false) + }, [tracking]) + + // ---- camera controls ---------------------------------------------------- + + useEffect(() => { + if (!scene) return + applyingScaleRef.current = true + applyCameraScale(scene, baseDistanceRef.current, previewScale) + applyingScaleRef.current = false + scene.requestRender() + }, [scene, previewScale]) + + useEffect(() => { + if (!scene) return + const { controls } = scene + controls.mouseButtons = { + LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.DOLLY, + RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN + } + controls.touches = { + ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE, + TWO: THREE.TOUCH.DOLLY_PAN + } + }, [scene, panMode]) + + usePinchZoom({ + containerRef: paneRef, + scale: previewScale, + onScaleChange: setPreviewScale, + enabled: scene != null + }) + + const resetView = useCallback(() => { + const instance = sceneRef.current + if (!instance) return + instance.controls.reset() + captureBaseDistance(instance) + setPreviewScale(1) + instance.requestRender() + }, [captureBaseDistance]) + + // ---- handlers ----------------------------------------------------------- + + const toggleType = useCallback((type) => { + setHiddenTypes((prev) => { + const next = new Set(prev) + if (next.has(type)) next.delete(type) + else next.add(type) + return next + }) + }, []) + + const handleRangeChange = useCallback( + (range) => { + setRangeOverrides((prev) => { + const next = { ...prev } + if (range == null) delete next[colorMode] + else next[colorMode] = range + return next + }) + }, + [colorMode] + ) + + const handleScrubChange = useCallback((value) => { + setScrubPlaying(false) + setScrubSegment(value) + }, []) + + const togglePlay = useCallback(() => { + if (scrubPlaying) { + setScrubPlaying(false) + return + } + setScrubSegment((prev) => (prev == null || prev >= segmentCount ? 0 : prev)) + setScrubPlaying(true) + }, [scrubPlaying, segmentCount]) + + // ---- derived display values ------------------------------------------- + + const showScrubber = ready && !tracking && segmentCount > 0 + const scrubValue = + scrubSegment == null ? segmentCount : Math.min(scrubSegment, segmentCount) + + const statsMoveIndex = useMemo(() => { + if (!ready || !model) return 0 + if (tracking && gcodePosition) { + const offset = fileOffsetForPrinter( + gcodePosition.filePosition, + gcodePosition.fileSize, + model.byteLength || model.size + ) + const fileSegment = model.store.segmentAtByteOffset(offset) + if (fileSegment < 0) return 0 + const live = isFinitePosition(toolPosition) + ? toolPosition + : isFinitePosition(gcodePosition.position) + ? gcodePosition.position + : null + const refined = refineSegmentByPosition(model.store, fileSegment, live) + return refined.segment + 1 + } + return scrubValue + // revision: segment store grows while chunks stream in during tracking. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, model, tracking, gcodePosition, toolPosition, scrubValue, revision]) + + const statsLayerIndex = + layerCount > 0 && statsMoveIndex > 0 + ? layerIndexForSegment( + layers, + Math.max(0, Math.round(statsMoveIndex) - 1) + ) + : -1 + + const layerSliderValue = layerRange || [1, Math.max(1, layerCount)] + const layerTooltip = useCallback( + (value) => { + const layer = layers[value - 1] + if (!layer) return value + return `Layer ${value} · Z ${layer.z.toFixed(2)}` + }, + [layers] + ) + + const loadingPercent = Math.round((loader.progress || 0) * 100) + const loadingLabel = loader.planning + ? 'Calculating motion...' + : `Parsing G-code... ${segmentCount.toLocaleString()} moves` + + return ( + + + setPanMode((prev) => !prev)} + > + + + ) : null} + + ) +} + +GCodeLegend.propTypes = { + mode: PropTypes.object, + model: PropTypes.object, + isDarkMode: PropTypes.bool, + hiddenTypes: PropTypes.instanceOf(Set).isRequired, + onToggleType: PropTypes.func.isRequired, + showTravel: PropTypes.bool, + onToggleTravel: PropTypes.func.isRequired, + range: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }), + autoRange: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }), + onRangeChange: PropTypes.func.isRequired +} + +export default GCodeLegend diff --git a/src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js b/src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js new file mode 100644 index 00000000..52d22fd8 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/GCodeScene.js @@ -0,0 +1,169 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import Bed, { expandVolumeToBounds, normalizeBuildVolume } from './Bed.js' +import HotEnd from './HotEnd.js' +import SegmentMesh from './SegmentMesh.js' + +// Owns the renderer, camera, controls and the scene graph. Rendering is on +// demand: anything that changes what is on screen calls requestRender(). +export default class GCodeScene { + constructor(canvas) { + this.canvas = canvas + this.disposed = false + this.renderRequested = false + this.frame = this.frame.bind(this) + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: false, + powerPreference: 'high-performance' + }) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + + this.scene = new THREE.Scene() + this.camera = new THREE.PerspectiveCamera(40, 1, 1, 50000) + this.camera.up.set(0, 0, 1) + this.scene.add(this.camera) + + const hemisphere = new THREE.HemisphereLight(0xffffff, 0x606060, 1.6) + this.scene.add(hemisphere) + // Fixed world-space key light (matches the extrusion shader's key light) + // plus a softer camera-attached fill. + const key = new THREE.DirectionalLight(0xffffff, 2.8) + key.position.set(-0.45, -0.35, 1) + this.scene.add(key) + const fill = new THREE.DirectionalLight(0xffffff, 1.1) + fill.position.set(0.3, 0.2, 1) + this.camera.add(fill) + this.lights = [hemisphere, key, fill] + + this.controls = new OrbitControls(this.camera, canvas) + this.controls.enableDamping = false + this.controls.screenSpacePanning = true + this.controls.addEventListener('change', () => this.requestRender()) + + this.bed = new Bed() + this.segments = new SegmentMesh() + this.hotEnd = new HotEnd() + this.scene.add(this.bed.group, this.segments.group, this.hotEnd.group) + + this.buildVolume = normalizeBuildVolume(null) + this.resize() + this.fitToVolume(this.buildVolume) + } + + // ---- camera ----------------------------------------------------------- + + fitToVolume(volume) { + const { minX, maxX, minY, maxY, maxZ } = volume + const sizeX = maxX - minX + const sizeY = maxY - minY + // Frame the bed footprint; a tall build volume should not push the + // camera away from where the first layers are printed. + const center = new THREE.Vector3( + (minX + maxX) / 2, + (minY + maxY) / 2, + Math.min(maxZ * 0.2, Math.max(sizeX, sizeY) * 0.15) + ) + const radius = 0.5 * Math.sqrt(sizeX * sizeX + sizeY * sizeY) + const fov = THREE.MathUtils.degToRad(this.camera.fov) + const aspect = this.camera.aspect || 1 + const horizontalFov = 2 * Math.atan(Math.tan(fov / 2) * aspect) + const limitingFov = Math.min(fov, horizontalFov) + const distance = (radius / Math.sin(limitingFov / 2)) * 0.8 + const direction = new THREE.Vector3(0, -1, 0.75).normalize() + this.camera.position.copy(center).addScaledVector(direction, distance) + this.camera.near = Math.max(0.5, distance / 1000) + this.camera.updateProjectionMatrix() + this.controls.target.copy(center) + this.controls.update() + this.controls.saveState() + this.requestRender() + } + + setBuildVolume(volume, bounds) { + const next = expandVolumeToBounds(normalizeBuildVolume(volume), bounds) + const changed = + next.minX !== this.buildVolume.minX || + next.maxX !== this.buildVolume.maxX || + next.minY !== this.buildVolume.minY || + next.maxY !== this.buildVolume.maxY || + next.maxZ !== this.buildVolume.maxZ + if (!changed) return false + this.buildVolume = next + this.bed.setVolume(next) + this.requestRender() + return true + } + + resize() { + const canvas = this.canvas + const width = canvas.clientWidth || 1 + const height = canvas.clientHeight || 1 + this.renderer.setSize(width, height, false) + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + this.requestRender() + } + + // ---- rendering -------------------------------------------------------- + + requestRender() { + if (this.renderRequested || this.disposed) return + this.renderRequested = true + requestAnimationFrame(this.frame) + } + + frame() { + this.renderRequested = false + if (this.disposed) return + const animating = this.hotEnd.update() + this.camera.updateMatrixWorld() + this.segments.updateLights(this.camera) + this.renderer.render(this.scene, this.camera) + if (animating) this.requestRender() + } + + // ---- model ------------------------------------------------------------ + + setStore(store) { + this.segments.setStore(store) + this.requestRender() + } + + syncStore() { + this.segments.sync() + this.requestRender() + } + + setToolPosition(x, y, z, immediate = false) { + if (![x, y, z].every(Number.isFinite)) return + this.hotEnd.setTarget(x, y, z, immediate) + this.requestRender() + } + + setHotEndVisible(visible) { + if (this.hotEnd.group.visible === visible) return + this.hotEnd.setVisible(visible) + this.requestRender() + } + + setTheme({ background, grid }) { + if (background) this.renderer.setClearColor(new THREE.Color(background), 1) + if (grid) this.bed.setColor(grid) + this.requestRender() + } + + dispose() { + this.disposed = true + this.controls.dispose() + this.segments.dispose() + this.bed.dispose() + this.hotEnd.dispose() + for (const light of this.lights) light.dispose?.() + // No forceContextLoss: React StrictMode re-mounts onto the same canvas + // and a lost context cannot be re-created on it. + this.renderer.dispose() + } +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/HotEnd.js b/src/components/Components/GCodePreview/GCodeViewer/HotEnd.js new file mode 100644 index 00000000..4c3cc653 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/HotEnd.js @@ -0,0 +1,106 @@ +import * as THREE from 'three' + +// Stylised hot end (nozzle, heater block, heat break, finned heatsink) with +// the nozzle tip at the group origin pointing down -Z. Dimensions in mm. +export default class HotEnd { + constructor() { + this.group = new THREE.Group() + this.group.visible = false + this.target = new THREE.Vector3() + this.hasTarget = false + this.materials = [] + this.geometries = [] + this.build() + } + + material(color, options = {}) { + const material = new THREE.MeshStandardMaterial({ + color, + metalness: options.metalness ?? 0.6, + roughness: options.roughness ?? 0.4 + }) + this.materials.push(material) + return material + } + + cylinder(radiusTop, radiusBottom, height, z, material, segments = 24) { + const geometry = new THREE.CylinderGeometry( + radiusTop, + radiusBottom, + height, + segments + ) + geometry.rotateX(Math.PI / 2) + geometry.translate(0, 0, z + height / 2) + this.geometries.push(geometry) + const mesh = new THREE.Mesh(geometry, material) + this.group.add(mesh) + return mesh + } + + box(width, depth, height, x, y, z, material) { + const geometry = new THREE.BoxGeometry(width, depth, height) + geometry.translate(x, y, z + height / 2) + this.geometries.push(geometry) + const mesh = new THREE.Mesh(geometry, material) + this.group.add(mesh) + return mesh + } + + build() { + const brass = this.material(0xc9a227, { metalness: 0.8, roughness: 0.35 }) + const steel = this.material(0x8d9299, { metalness: 0.7, roughness: 0.5 }) + const block = this.material(0x5c6066, { metalness: 0.5, roughness: 0.6 }) + const fins = this.material(0xb9bec6, { metalness: 0.7, roughness: 0.45 }) + const sock = this.material(0x2f6fdb, { metalness: 0.1, roughness: 0.9 }) + + // Nozzle tip and hex body. + this.cylinder(1.6, 0.35, 2.5, 0, brass) + this.cylinder(3.2, 3.2, 3, 2.5, brass, 6) + // Heater block with a silicone sock. + this.box(16, 10, 11, 3, 0, 5.5, block) + this.box(17, 11, 9, 3, 0, 6.5, sock) + // Heat break. + this.cylinder(1.6, 1.6, 6, 16.5, steel) + // Heatsink core and fins. + this.cylinder(4, 4, 24, 22.5, fins) + for (let i = 0; i < 8; i++) { + this.cylinder(10.5, 10.5, 1, 23 + i * 3, fins, 32) + } + } + + setTarget(x, y, z, immediate = false) { + this.target.set(x, y, z) + this.hasTarget = true + if (immediate || !this.group.visible) { + this.group.position.copy(this.target) + } + } + + setVisible(visible) { + if (visible && !this.group.visible && this.hasTarget) { + this.group.position.copy(this.target) + } + this.group.visible = visible + } + + // Eases towards the target; returns true while still moving. + update() { + if (!this.group.visible || !this.hasTarget) return false + const position = this.group.position + const distance = position.distanceTo(this.target) + if (distance < 0.01) { + position.copy(this.target) + return false + } + position.lerp(this.target, distance > 50 ? 1 : 0.35) + return true + } + + dispose() { + for (const geometry of this.geometries) geometry.dispose() + for (const material of this.materials) material.dispose() + this.geometries = [] + this.materials = [] + } +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/SegmentMesh.js b/src/components/Components/GCodePreview/GCodeViewer/SegmentMesh.js new file mode 100644 index 00000000..fd97e2e9 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/SegmentMesh.js @@ -0,0 +1,311 @@ +import * as THREE from 'three' +import { + createExtrusionMaterial, + createLineMaterial, + createSharedUniforms, + MAX_TOOLS +} from './ExtrusionMaterial.js' +import { + ALL_TYPES_MASK, + EXTRUSION_TYPES_MASK, + FEATURE_INFO, + LINE_TYPES_MASK, + MAX_FEATURE_TYPES +} from './featureTypes.js' +import { TOOL_PALETTE } from './colorModes.js' + +// GPU representation of a SegmentStore. Every segment is one instance; the +// box geometry renders extrusions and the two-vertex line geometry renders +// travels (and everything in "lines" mode). Both geometries share the same +// attribute objects, so each field lives in GPU memory exactly once. + +function createBoxBase() { + const box = new THREE.BoxGeometry(1, 1, 1) + box.translate(0.5, 0, 0) + return box +} + +function createLineBase() { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3) + ) + return geometry +} + +const INSTANCE_FIELDS = [ + ['aType', 'type'], + ['aTool', 'tool'], + ['aFan', 'fan'], + ['aLayer', 'layer'], + ['aTemp', 'temp'], + ['aWidth', 'width'], + ['aHeight', 'height'], + ['aFeed', 'feed'], + ['aAccel', 'accel'], + ['aActualSpeed', 'actualSpeed'], + ['aLayerTime', 'layerTime'] +] + +export default class SegmentMesh { + constructor() { + this.group = new THREE.Group() + this.shared = createSharedUniforms() + this.boxMaterial = createExtrusionMaterial(this.shared) + this.lineMaterial = createLineMaterial(this.shared) + this.boxBase = createBoxBase() + this.lineBase = createLineBase() + // World-space light directions; converted to view space per frame. + this.keyLightWorld = new THREE.Vector3(-0.45, -0.35, 1).normalize() + this.fillLightWorld = new THREE.Vector3(0.7, 0.6, 0.25).normalize() + + this.boxGeometry = null + this.lineGeometry = null + this.mesh = null + this.lines = null + this.positionBuffer = null + this.attributes = {} + + this.store = null + this.generation = -1 + this.uploaded = 0 + this.plannerUploaded = false + + this.renderMode = 'extrusion' + this.visibleMask = ALL_TYPES_MASK & ~(1 << 1) // travel hidden by default + this.applyMasks() + this.setTypeColors() + this.setToolColors() + } + + setStore(store) { + this.store = store + this.generation = -1 + this.uploaded = 0 + this.plannerUploaded = false + this.shared.uVisibleEnd.value = 0 + this.sync() + } + + // Upload anything new since the last sync. Cheap when nothing changed. + sync() { + const store = this.store + if (!store) { + this.disposeGeometry() + return + } + if (store.generation !== this.generation) { + this.rebuild() + } else if (store.count > this.uploaded) { + this.uploadRange(this.uploaded, store.count) + } + if (store.plannerReady && !this.plannerUploaded) { + this.uploadPlanner() + } + this.uploaded = store.count + if (this.boxGeometry) this.boxGeometry.instanceCount = store.count + if (this.lineGeometry) this.lineGeometry.instanceCount = store.count + } + + rebuild() { + this.disposeGeometry() + const store = this.store + this.generation = store.generation + + const positionBuffer = new THREE.InstancedInterleavedBuffer( + store.positions, + 3, + 1 + ) + positionBuffer.setUsage(THREE.DynamicDrawUsage) + this.positionBuffer = positionBuffer + const instanceStart = new THREE.InterleavedBufferAttribute( + positionBuffer, + 3, + 0 + ) + const instanceEnd = new THREE.InterleavedBufferAttribute( + positionBuffer, + 3, + 3 + ) + + this.attributes = {} + for (const [name, field] of INSTANCE_FIELDS) { + const attribute = new THREE.InstancedBufferAttribute(store[field], 1) + attribute.setUsage(THREE.DynamicDrawUsage) + this.attributes[name] = attribute + } + + const applyInstanceAttributes = (geometry) => { + geometry.setAttribute('instanceStart', instanceStart) + geometry.setAttribute('instanceEnd', instanceEnd) + for (const name of Object.keys(this.attributes)) { + geometry.setAttribute(name, this.attributes[name]) + } + geometry.instanceCount = store.count + // The bounding volume would otherwise be computed from the base + // geometry only; culling is skipped since the model fills the view. + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6) + } + + const boxGeometry = new THREE.InstancedBufferGeometry() + boxGeometry.index = this.boxBase.index + boxGeometry.setAttribute('position', this.boxBase.attributes.position) + boxGeometry.setAttribute('normal', this.boxBase.attributes.normal) + applyInstanceAttributes(boxGeometry) + + const lineGeometry = new THREE.InstancedBufferGeometry() + lineGeometry.setAttribute('position', this.lineBase.attributes.position) + applyInstanceAttributes(lineGeometry) + + this.boxGeometry = boxGeometry + this.lineGeometry = lineGeometry + + this.mesh = new THREE.Mesh(boxGeometry, this.boxMaterial) + this.mesh.frustumCulled = false + this.mesh.visible = this.renderMode === 'extrusion' + this.lines = new THREE.LineSegments(lineGeometry, this.lineMaterial) + this.lines.frustumCulled = false + this.group.add(this.mesh, this.lines) + + this.uploaded = store.count + this.plannerUploaded = false + } + + uploadRange(from, to) { + if (to <= from) return + const count = to - from + // Segment `to - 1` ends at vertex `to`, so include one extra vertex. + this.positionBuffer.addUpdateRange(from * 3, (count + 1) * 3) + this.positionBuffer.needsUpdate = true + for (const [name] of INSTANCE_FIELDS) { + if (name === 'aActualSpeed' || name === 'aLayerTime') continue + const attribute = this.attributes[name] + attribute.addUpdateRange(from, count) + attribute.needsUpdate = true + } + } + + uploadPlanner() { + for (const name of ['aActualSpeed', 'aLayerTime']) { + const attribute = this.attributes[name] + if (!attribute) continue + attribute.addUpdateRange(0, this.store.count) + attribute.needsUpdate = true + } + this.plannerUploaded = true + } + + disposeGeometry() { + if (this.mesh) this.group.remove(this.mesh) + if (this.lines) this.group.remove(this.lines) + this.boxGeometry?.dispose() + this.lineGeometry?.dispose() + this.boxGeometry = null + this.lineGeometry = null + this.mesh = null + this.lines = null + this.positionBuffer = null + this.attributes = {} + } + + dispose() { + this.disposeGeometry() + this.boxBase.dispose() + this.lineBase.dispose() + this.boxMaterial.dispose() + this.lineMaterial.dispose() + } + + // ---- uniform driven state ------------------------------------------- + + // Call before rendering so the fixed world lights follow camera rotation. + updateLights(camera) { + const { uKeyLightDir, uFillLightDir } = this.boxMaterial.uniforms + uKeyLightDir.value + .copy(this.keyLightWorld) + .transformDirection(camera.matrixWorldInverse) + uFillLightDir.value + .copy(this.fillLightWorld) + .transformDirection(camera.matrixWorldInverse) + } + + applyMasks() { + const mask = this.visibleMask & ALL_TYPES_MASK + if (this.renderMode === 'lines') { + this.boxMaterial.uniforms.uTypeMask.value = 0 + this.lineMaterial.uniforms.uTypeMask.value = mask + this.lineMaterial.uniforms.uOpacity.value = 1 + this.lineMaterial.transparent = false + this.lineMaterial.depthWrite = true + } else { + this.boxMaterial.uniforms.uTypeMask.value = mask & EXTRUSION_TYPES_MASK + this.lineMaterial.uniforms.uTypeMask.value = mask & LINE_TYPES_MASK + this.lineMaterial.uniforms.uOpacity.value = 0.7 + this.lineMaterial.transparent = true + this.lineMaterial.depthWrite = false + } + if (this.mesh) this.mesh.visible = this.renderMode === 'extrusion' + } + + setRenderMode(mode) { + this.renderMode = mode === 'lines' ? 'lines' : 'extrusion' + this.applyMasks() + } + + setVisibleMask(mask) { + this.visibleMask = mask + this.applyMasks() + } + + setColorMode(shaderMode, logScale = false) { + this.shared.uColorMode.value = shaderMode | 0 + this.shared.uLogScale.value = logScale ? 1 : 0 + } + + setRange(min, max) { + this.shared.uRangeMin.value = Number.isFinite(min) ? min : 0 + this.shared.uRangeMax.value = Number.isFinite(max) ? max : 1 + } + + setLayerRange(min, max) { + this.shared.uLayerMin.value = min + this.shared.uLayerMax.value = max + } + + // Show segments [0, end) fully and fraction `t` of segment `end - 1`. + setProgress(end, t = 1) { + const count = this.store ? this.store.count : 0 + const clamped = Math.max(0, Math.min(count, end | 0)) + this.shared.uVisibleEnd.value = clamped + this.shared.uProgressT.value = Math.max(0, Math.min(1, t)) + } + + showAll() { + this.shared.uVisibleEnd.value = this.store ? this.store.count : 0 + this.shared.uProgressT.value = 1 + } + + setTypeColors(overrides = {}) { + const colors = this.shared.uTypeColors.value + for (let i = 0; i < MAX_FEATURE_TYPES; i++) { + const info = FEATURE_INFO[i] + const hex = overrides[i] || info?.color || '#888888' + colors[i].set(hex) + } + } + + setToolColors(hexColors = []) { + const colors = this.shared.uToolColors.value + for (let i = 0; i < MAX_TOOLS; i++) { + const hex = hexColors[i] || TOOL_PALETTE[i % TOOL_PALETTE.length] + try { + colors[i].set(hex) + } catch { + colors[i].set(TOOL_PALETTE[i % TOOL_PALETTE.length]) + } + } + } +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/colorModes.js b/src/components/Components/GCodePreview/GCodeViewer/colorModes.js new file mode 100644 index 00000000..a704d660 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/colorModes.js @@ -0,0 +1,200 @@ +// Colour modes mirror Orca Slicer's preview "view type" list, plus +// acceleration. `shaderMode` is the integer the shaders switch on; `stat` is +// the key in the worker's range statistics used for auto ranging. + +export const SHADER_MODE = Object.freeze({ + FEATURE: 0, + HEIGHT: 1, + WIDTH: 2, + SPEED: 3, + ACTUAL_SPEED: 4, + ACCEL: 5, + FAN: 6, + TEMP: 7, + FLOW: 8, + ACTUAL_FLOW: 9, + LAYER_TIME: 10, + TOOL: 11 +}) + +const formatNumber = (digits) => (value) => + Number.isFinite(value) ? value.toFixed(digits) : '-' + +export function formatDuration(seconds) { + if (!Number.isFinite(seconds)) return '-' + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s` + const minutes = Math.floor(seconds / 60) + const rest = Math.round(seconds - minutes * 60) + if (minutes < 60) return `${minutes}m ${rest}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes - hours * 60}m` +} + +export const COLOR_MODES = [ + { + value: 'feature', + label: 'Line type', + kind: 'category', + shaderMode: SHADER_MODE.FEATURE + }, + { + value: 'height', + label: 'Height', + kind: 'scalar', + shaderMode: SHADER_MODE.HEIGHT, + stat: 'height', + unit: 'mm', + format: formatNumber(2) + }, + { + value: 'width', + label: 'Width', + kind: 'scalar', + shaderMode: SHADER_MODE.WIDTH, + stat: 'width', + unit: 'mm', + format: formatNumber(2) + }, + { + value: 'speed', + label: 'Speed', + kind: 'scalar', + shaderMode: SHADER_MODE.SPEED, + stat: 'feed', + unit: 'mm/s', + format: formatNumber(0) + }, + { + value: 'actualSpeed', + label: 'Actual speed', + kind: 'scalar', + shaderMode: SHADER_MODE.ACTUAL_SPEED, + stat: 'actualSpeed', + unit: 'mm/s', + format: formatNumber(0), + requiresPlanner: true + }, + { + value: 'accel', + label: 'Acceleration', + kind: 'scalar', + shaderMode: SHADER_MODE.ACCEL, + stat: 'accel', + unit: 'mm/s²', + format: formatNumber(0) + }, + { + value: 'fan', + label: 'Fan speed', + kind: 'scalar', + shaderMode: SHADER_MODE.FAN, + stat: 'fan', + unit: '%', + format: formatNumber(0) + }, + { + value: 'temp', + label: 'Temperature', + kind: 'scalar', + shaderMode: SHADER_MODE.TEMP, + stat: 'temp', + unit: '°C', + format: formatNumber(0) + }, + { + value: 'flow', + label: 'Volumetric flow', + kind: 'scalar', + shaderMode: SHADER_MODE.FLOW, + stat: 'flow', + unit: 'mm³/s', + format: formatNumber(1) + }, + { + value: 'actualFlow', + label: 'Actual volumetric flow', + kind: 'scalar', + shaderMode: SHADER_MODE.ACTUAL_FLOW, + stat: 'actualFlow', + unit: 'mm³/s', + format: formatNumber(1), + requiresPlanner: true + }, + { + value: 'layerTime', + label: 'Layer time', + kind: 'scalar', + shaderMode: SHADER_MODE.LAYER_TIME, + stat: 'layerTime', + unit: '', + format: formatDuration, + requiresPlanner: true + }, + { + value: 'layerTimeLog', + label: 'Layer time (log)', + kind: 'scalar', + shaderMode: SHADER_MODE.LAYER_TIME, + stat: 'layerTime', + unit: '', + format: formatDuration, + logScale: true, + requiresPlanner: true + }, + { + value: 'tool', + label: 'Tool', + kind: 'tool', + shaderMode: SHADER_MODE.TOOL + }, + { + value: 'filament', + label: 'Filament', + kind: 'tool', + shaderMode: SHADER_MODE.TOOL, + useFilamentColors: true + } +] + +export const COLOR_MODE_BY_VALUE = new Map( + COLOR_MODES.map((mode) => [mode.value, mode]) +) + +// Orca Slicer's scalar ramp (blue -> green -> yellow -> red), 11 stops. +export const RAMP_STOPS = [ + '#0b2c7a', + '#135985', + '#1c8891', + '#04d60f', + '#aaf200', + '#fcf803', + '#f5ce0a', + '#e38820', + '#d16830', + '#c2523c', + '#942616' +] + +// Distinct colours for tools when the file has no filament colours. +export const TOOL_PALETTE = [ + '#00a2ff', + '#ff8b2d', + '#2dd36f', + '#ff4d6d', + '#b36bff', + '#ffd43b', + '#20c9b8', + '#ff6b00', + '#7a9cff', + '#d6336c', + '#69db7c', + '#f783ac', + '#4dabf7', + '#ffa94d', + '#9775fa', + '#a9e34b' +] + +export function rampCss() { + return `linear-gradient(to right, ${RAMP_STOPS.join(', ')})` +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/featureTypes.js b/src/components/Components/GCodePreview/GCodeViewer/featureTypes.js new file mode 100644 index 00000000..90d48f32 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/featureTypes.js @@ -0,0 +1,142 @@ +// Canonical feature (line) types shared by the worker, the shaders and the UI. +// Index 0 is reserved for segments that must never render (position jumps), +// index 1 is travel. Everything above is an extrusion type. + +export const FEATURE = Object.freeze({ + NONE: 0, + TRAVEL: 1, + OUTER_WALL: 2, + INNER_WALL: 3, + OVERHANG_WALL: 4, + SPARSE_INFILL: 5, + SOLID_INFILL: 6, + TOP_SURFACE: 7, + BOTTOM_SURFACE: 8, + IRONING: 9, + BRIDGE: 10, + GAP_FILL: 11, + SKIRT: 12, + BRIM: 13, + SUPPORT: 14, + SUPPORT_INTERFACE: 15, + PRIME_TOWER: 16, + WIPE: 17, + CUSTOM: 18, + UNKNOWN: 19 +}) + +export const FEATURE_COUNT = 20 +export const MAX_FEATURE_TYPES = 32 + +// Orca Slicer palette. +export const FEATURE_INFO = [ + { id: FEATURE.NONE, label: 'Hidden', color: '#000000', hidden: true }, + { id: FEATURE.TRAVEL, label: 'Travel', color: '#8c8c8c', travel: true }, + { id: FEATURE.OUTER_WALL, label: 'Outer wall', color: '#ff7d38' }, + { id: FEATURE.INNER_WALL, label: 'Inner wall', color: '#ffe64d' }, + { id: FEATURE.OVERHANG_WALL, label: 'Overhang wall', color: '#1f4cff' }, + { id: FEATURE.SPARSE_INFILL, label: 'Sparse infill', color: '#b03c7f' }, + { + id: FEATURE.SOLID_INFILL, + label: 'Internal solid infill', + color: '#9c0ed0' + }, + { id: FEATURE.TOP_SURFACE, label: 'Top surface', color: '#ff3d3d' }, + { id: FEATURE.BOTTOM_SURFACE, label: 'Bottom surface', color: '#ff6669' }, + { id: FEATURE.IRONING, label: 'Ironing', color: '#ffc8ff' }, + { id: FEATURE.BRIDGE, label: 'Bridge', color: '#4d80bd' }, + { id: FEATURE.GAP_FILL, label: 'Gap infill', color: '#ffffff' }, + { id: FEATURE.SKIRT, label: 'Skirt', color: '#00ffff' }, + { id: FEATURE.BRIM, label: 'Brim', color: '#6fe0ff' }, + { id: FEATURE.SUPPORT, label: 'Support', color: '#00ff00' }, + { + id: FEATURE.SUPPORT_INTERFACE, + label: 'Support interface', + color: '#4dff80' + }, + { id: FEATURE.PRIME_TOWER, label: 'Prime tower', color: '#b3b3b3' }, + { id: FEATURE.WIPE, label: 'Wipe', color: '#ffff7d' }, + { id: FEATURE.CUSTOM, label: 'Custom', color: '#e6b35c' }, + { id: FEATURE.UNKNOWN, label: 'Other', color: '#b8b8b8' } +] + +// Normalised slicer strings -> feature. Keys are lower-cased and have +// whitespace, dashes and underscores stripped so the same table handles +// Orca, Bambu, Prusa, SuperSlicer, Cura and ideaMaker variants. +const FEATURE_LOOKUP = new Map( + Object.entries({ + // Orca / Bambu + outerwall: FEATURE.OUTER_WALL, + innerwall: FEATURE.INNER_WALL, + overhangwall: FEATURE.OVERHANG_WALL, + sparseinfill: FEATURE.SPARSE_INFILL, + internalsolidinfill: FEATURE.SOLID_INFILL, + topsurface: FEATURE.TOP_SURFACE, + bottomsurface: FEATURE.BOTTOM_SURFACE, + ironing: FEATURE.IRONING, + bridge: FEATURE.BRIDGE, + internalbridge: FEATURE.BRIDGE, + gapinfill: FEATURE.GAP_FILL, + skirt: FEATURE.SKIRT, + brim: FEATURE.BRIM, + support: FEATURE.SUPPORT, + supportinterface: FEATURE.SUPPORT_INTERFACE, + supporttransition: FEATURE.SUPPORT, + primetower: FEATURE.PRIME_TOWER, + custom: FEATURE.CUSTOM, + multiple: FEATURE.UNKNOWN, + wipe: FEATURE.WIPE, + // Prusa / SuperSlicer + perimeter: FEATURE.INNER_WALL, + externalperimeter: FEATURE.OUTER_WALL, + overhangperimeter: FEATURE.OVERHANG_WALL, + internalinfill: FEATURE.SPARSE_INFILL, + solidinfill: FEATURE.SOLID_INFILL, + topsolidinfill: FEATURE.TOP_SURFACE, + bottomsolidinfill: FEATURE.BOTTOM_SURFACE, + bridgeinfill: FEATURE.BRIDGE, + internalbridgeinfill: FEATURE.BRIDGE, + thinwall: FEATURE.GAP_FILL, + gapfill: FEATURE.GAP_FILL, + 'skirt/brim': FEATURE.SKIRT, + skirtbrim: FEATURE.SKIRT, + supportmaterial: FEATURE.SUPPORT, + supportmaterialinterface: FEATURE.SUPPORT_INTERFACE, + wipetower: FEATURE.PRIME_TOWER, + // Cura + wallouter: FEATURE.OUTER_WALL, + wallinner: FEATURE.INNER_WALL, + skin: FEATURE.TOP_SURFACE, + fill: FEATURE.SPARSE_INFILL, + supportinterfacecura: FEATURE.SUPPORT_INTERFACE, + // ideaMaker / Simplify3D + outershell: FEATURE.OUTER_WALL, + innershell: FEATURE.INNER_WALL, + infill: FEATURE.SPARSE_INFILL, + solidlayer: FEATURE.SOLID_INFILL, + raft: FEATURE.SUPPORT, + ooze: FEATURE.PRIME_TOWER + }) +) + +export function featureFromSlicerString(value) { + if (!value) return FEATURE.UNKNOWN + const key = String(value) + .trim() + .toLowerCase() + .replace(/[\s\-_]+/g, '') + return FEATURE_LOOKUP.get(key) ?? FEATURE.UNKNOWN +} + +// Travel and wipe moves carry no material and are drawn as lines. +export function isLineFeature(type) { + return type <= FEATURE.TRAVEL || type === FEATURE.WIPE +} + +export function isExtrusionFeature(type) { + return type > FEATURE.TRAVEL && type !== FEATURE.WIPE +} + +export const ALL_TYPES_MASK = (1 << FEATURE_COUNT) - 1 +export const LINE_TYPES_MASK = (1 << FEATURE.TRAVEL) | (1 << FEATURE.WIPE) +export const EXTRUSION_TYPES_MASK = ALL_TYPES_MASK & ~LINE_TYPES_MASK & ~1 diff --git a/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js b/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js new file mode 100644 index 00000000..94e00497 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js @@ -0,0 +1,1313 @@ +// Streaming G-code parser. Runs off the main thread, scans raw bytes (so byte +// offsets match Klipper's virtual_sdcard.file_position exactly) and ships +// segments to the main thread as transferable typed arrays while the file is +// still downloading. When the whole file is parsed it runs the motion planner +// and sends the derived per-segment data plus file metadata. + +import SegmentStore from './segmentStore.js' +import { + FEATURE, + MAX_FEATURE_TYPES, + featureFromSlicerString, + isExtrusionFeature +} from './featureTypes.js' +import { planMotion, computeLayerTimes } from './planner.js' + +const CH_TAB = 9 +const CH_LF = 10 +const CH_CR = 13 +const CH_SPACE = 32 +const CH_STAR = 42 +const CH_PLUS = 43 +const CH_MINUS = 45 +const CH_DOT = 46 +const CH_0 = 48 +const CH_9 = 57 +const CH_SEMI = 59 +const CH_A = 65 +const CH_Z = 90 +const CH_UNDERSCORE = 95 +const CH_a = 97 +const CH_z = 122 + +const POW10 = [1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12] + +const CHUNK_SEGMENTS = 32768 +const CHUNK_INTERVAL_MS = 120 +const ARC_TOLERANCE = 0.02 +const DEFAULT_LAYER_HEIGHT = 0.2 +const DEFAULT_ACCEL = 3000 +const DEFAULT_MAX_VELOCITY = 500 +const DEFAULT_SCV = 5 + +const isDigit = (c) => c >= CH_0 && c <= CH_9 +const isLetter = (c) => (c >= CH_A && c <= CH_Z) || (c >= CH_a && c <= CH_z) + +// Word bit flags for G0/G1/G2/G3 argument presence. +const W_X = 1 +const W_Y = 2 +const W_Z = 4 +const W_E = 8 +const W_F = 16 +const W_I = 32 +const W_J = 64 +const W_R = 128 +const W_S = 256 +const W_P = 512 +const W_T = 1024 + +class GCodeParser { + constructor(post, expectedBytes) { + this.post = post + this.decoder = new TextDecoder('utf-8') + const capacity = expectedBytes > 0 ? Math.ceil(expectedBytes / 28) : 65536 + this.store = new SegmentStore(Math.min(capacity, 50_000_000)) + + // Number parsing scratch. + this.num = 0 + this.numOk = false + this.wX = 0 + this.wY = 0 + this.wZ = 0 + this.wE = 0 + this.wF = 0 + this.wI = 0 + this.wJ = 0 + this.wR = 0 + this.wS = 0 + this.wP = 0 + this.wT = 0 + this.statsLayerMarker = false + + // Physical position and G92 offsets. + this.px = 0 + this.py = 0 + this.pz = 0 + this.pe = 0 + this.offX = 0 + this.offY = 0 + this.offZ = 0 + this.offE = 0 + this.absolute = true + this.absoluteE = true + this.extruderModeExplicit = false + this.unit = 1 + + // Modal state. + this.feed = 0 + this.tool = 0 + this.fan = 0 + this.temp = 0 + this.accel = 0 + this.currentType = FEATURE.UNKNOWN + this.commentWidth = 0 + this.commentHeight = 0 + this.commentZ = NaN + this.wipeActive = false + this.sawTypeComment = false + + // Layers. + this.layers = [] + this.layerIndex = -1 + this.layerZ = NaN + this.prevLayerZ = NaN + this.layerHasExtrusion = false + this.markerSeen = false + this.estimatedLayerHeight = DEFAULT_LAYER_HEIGHT + + // Planner hints. + this.maxVelocity = 0 + this.squareCornerVelocity = 0 + this.defaultAccel = 0 + + this.filamentDiameter = 1.75 + this.filamentArea = Math.PI * 0.875 * 0.875 + this.nozzleDiameter = 0.4 + + this.metadata = { + generator: null, + buildVolume: null, + filamentColors: [], + filamentDiameter: 1.75, + nozzleDiameter: 0.4, + layerHeight: null, + estimatedTime: null, + totalLayers: null, + settings: {} + } + + this.toolsUsed = new Set() + this.typeCounts = new Uint32Array(MAX_FEATURE_TYPES) + + this.lineOffset = 0 + this.lineCount = 0 + this.sentCount = 0 + this.lastPostTime = 0 + } + + // --------------------------------------------------------------------- + // Byte scanning + // --------------------------------------------------------------------- + + readNumber(buf, i, end) { + let sign = 1 + if (i < end) { + if (buf[i] === CH_MINUS) { + sign = -1 + i++ + } else if (buf[i] === CH_PLUS) { + i++ + } + } + let intPart = 0 + let any = false + while (i < end && isDigit(buf[i])) { + intPart = intPart * 10 + (buf[i] - CH_0) + i++ + any = true + } + let value = intPart + if (i < end && buf[i] === CH_DOT) { + i++ + let frac = 0 + let digits = 0 + while (i < end && isDigit(buf[i])) { + if (digits < 12) { + frac = frac * 10 + (buf[i] - CH_0) + digits++ + } + i++ + any = true + } + if (digits > 0) value += frac / POW10[digits] + } + if (any && i < end && (buf[i] === 101 || buf[i] === 69)) { + // Exponent (rare). + let j = i + 1 + let expSign = 1 + if (j < end && buf[j] === CH_MINUS) { + expSign = -1 + j++ + } else if (j < end && buf[j] === CH_PLUS) { + j++ + } + if (j < end && isDigit(buf[j])) { + let exp = 0 + while (j < end && isDigit(buf[j])) { + exp = exp * 10 + (buf[j] - CH_0) + j++ + } + value *= Math.pow(10, expSign * exp) + i = j + } + } + this.num = sign * value + this.numOk = any + return i + } + + parseLine(buf, start, end, offset) { + this.lineOffset = offset + this.lineCount++ + let s = start + let e = end + if (e > s && buf[e - 1] === CH_CR) e-- + while (s < e && (buf[s] === CH_SPACE || buf[s] === CH_TAB)) s++ + if (s >= e) return + + let c = buf[s] + if (c === CH_SEMI) { + this.handleComment(buf, s + 1, e) + return + } + + // Optional line number prefix "N123 ". + if ((c === 78 || c === 110) && s + 1 < e && isDigit(buf[s + 1])) { + s += 1 + while (s < e && isDigit(buf[s])) s++ + while (s < e && (buf[s] === CH_SPACE || buf[s] === CH_TAB)) s++ + if (s >= e) return + c = buf[s] + } + + if (c >= CH_a && c <= CH_z) c -= 32 + const next = s + 1 < e ? buf[s + 1] : 0 + + if ( + (c === 71 || c === 77 || c === 84) && + (isDigit(next) || next === CH_DOT) + ) { + // G / M / T command + const i = this.readNumber(buf, s + 1, e) + const code = this.num + if (c === 71) this.handleG(code, buf, i, e) + else if (c === 77) this.handleM(code, buf, i, e) + else this.setTool(code | 0) + return + } + + if (isLetter(c)) { + this.handleExtended(buf, s, e) + } + } + + // Parses "X1.0 Y2.0 ..." words. Returns a bitmask of words found and + // stores their values on this.w*. + parseWords(buf, i, end) { + let mask = 0 + while (i < end) { + const c = buf[i] + if (c === CH_SPACE || c === CH_TAB) { + i++ + continue + } + if (c === CH_SEMI || c === CH_STAR) break + let letter = c + if (letter >= CH_a && letter <= CH_z) letter -= 32 + i = this.readNumber(buf, i + 1, end) + const v = this.numOk ? this.num : NaN + switch (letter) { + case 88: + this.wX = v + mask |= W_X + break + case 89: + this.wY = v + mask |= W_Y + break + case 90: + this.wZ = v + mask |= W_Z + break + case 69: + this.wE = v + mask |= W_E + break + case 70: + this.wF = v + mask |= W_F + break + case 73: + this.wI = v + mask |= W_I + break + case 74: + this.wJ = v + mask |= W_J + break + case 82: + this.wR = v + mask |= W_R + break + case 83: + this.wS = v + mask |= W_S + break + case 80: + this.wP = v + mask |= W_P + break + case 84: + this.wT = v + mask |= W_T + break + default: + break + } + // Skip anything that was not a number (e.g. "G28 X" flags) to avoid + // infinite loops on malformed words. + if (!this.numOk) { + while (i < end && buf[i] !== CH_SPACE && buf[i] !== CH_TAB) i++ + } + } + return mask + } + + // --------------------------------------------------------------------- + // Commands + // --------------------------------------------------------------------- + + handleG(code, buf, i, end) { + switch (code) { + case 0: + case 1: { + const mask = this.parseWords(buf, i, end) + this.linearMove(mask) + break + } + case 2: + case 3: { + const mask = this.parseWords(buf, i, end) + this.arcMove(mask, code === 2) + break + } + case 20: + this.unit = 25.4 + break + case 21: + this.unit = 1 + break + case 28: { + const mask = this.parseWords(buf, i, end) + const all = (mask & (W_X | W_Y | W_Z)) === 0 + const nx = all || mask & W_X ? 0 : this.px + const ny = all || mask & W_Y ? 0 : this.py + const nz = all || mask & W_Z ? 0 : this.pz + this.jumpTo(nx, ny, nz) + break + } + case 90: + this.absolute = true + // Klipper: G90/G91 never change the extruder mode once M82/M83 has + // set it. Only fall back to Marlin-style behaviour before that. + if (!this.extruderModeExplicit) this.absoluteE = true + break + case 91: + this.absolute = false + if (!this.extruderModeExplicit) this.absoluteE = false + break + case 92: { + const mask = this.parseWords(buf, i, end) + if (mask === 0) { + this.offX = this.px + this.offY = this.py + this.offZ = this.pz + this.offE = this.pe + break + } + if (mask & W_X && Number.isFinite(this.wX)) + this.offX = this.px - this.wX * this.unit + if (mask & W_Y && Number.isFinite(this.wY)) + this.offY = this.py - this.wY * this.unit + if (mask & W_Z && Number.isFinite(this.wZ)) + this.offZ = this.pz - this.wZ * this.unit + if (mask & W_E && Number.isFinite(this.wE)) + this.offE = this.pe - this.wE * this.unit + break + } + default: + break + } + } + + handleM(code, buf, i, end) { + switch (code) { + case 82: + this.absoluteE = true + this.extruderModeExplicit = true + break + case 83: + this.absoluteE = false + this.extruderModeExplicit = true + break + case 104: + case 109: { + const mask = this.parseWords(buf, i, end) + if (mask & W_S && Number.isFinite(this.wS)) this.temp = this.wS + break + } + case 106: { + const mask = this.parseWords(buf, i, end) + let value = mask & W_S && Number.isFinite(this.wS) ? this.wS : 255 + if (value < 0) value = 0 + if (value > 255) value = 255 + this.fan = value + break + } + case 107: + this.fan = 0 + break + case 204: { + const mask = this.parseWords(buf, i, end) + let accel = NaN + if (mask & W_S && Number.isFinite(this.wS)) accel = this.wS + else { + if (mask & W_P && Number.isFinite(this.wP)) accel = this.wP + if (mask & W_T && Number.isFinite(this.wT)) { + accel = Number.isFinite(accel) ? Math.min(accel, this.wT) : this.wT + } + } + if (accel > 0) this.accel = accel + break + } + default: + break + } + } + + handleExtended(buf, s, end) { + let i = s + while ( + i < end && + (isLetter(buf[i]) || buf[i] === CH_UNDERSCORE || isDigit(buf[i])) + ) { + i++ + } + const name = this.decoder.decode(buf.subarray(s, i)).toUpperCase() + if (name === 'SET_VELOCITY_LIMIT') { + const params = this.parseParams(buf, i, end) + const accel = parseFloat(params.ACCEL) + if (accel > 0) this.accel = accel + const vel = parseFloat(params.VELOCITY) + if (vel > 0) this.maxVelocity = vel + const scv = parseFloat(params.SQUARE_CORNER_VELOCITY) + if (scv > 0) this.squareCornerVelocity = scv + } else if (name === 'SET_PRINT_STATS_INFO') { + const params = this.parseParams(buf, i, end) + if (params.CURRENT_LAYER !== undefined && !this.markerSeen) { + // Only trust these when no slicer layer comments are present; Orca + // emits both and the comments carry the Z value. + this.statsLayerMarker = true + } + if (params.TOTAL_LAYER !== undefined) { + const total = parseInt(params.TOTAL_LAYER, 10) + if (total > 0) this.metadata.totalLayers = total + } + if (params.CURRENT_LAYER !== undefined && this.statsLayerMarker) { + this.beginLayer(NaN, true) + } + } else if (name === 'SET_FAN_SPEED') { + const params = this.parseParams(buf, i, end) + const speed = parseFloat(params.SPEED) + if (Number.isFinite(speed)) { + this.fan = Math.max(0, Math.min(255, speed * 255)) + } + } + } + + parseParams(buf, i, end) { + const text = this.decoder.decode(buf.subarray(i, end)) + const params = {} + const commentIndex = text.indexOf(';') + const body = commentIndex >= 0 ? text.slice(0, commentIndex) : text + for (const token of body.trim().split(/\s+/)) { + const eq = token.indexOf('=') + if (eq <= 0) continue + params[token.slice(0, eq).toUpperCase()] = token.slice(eq + 1) + } + return params + } + + // --------------------------------------------------------------------- + // Motion + // --------------------------------------------------------------------- + + setTool(tool) { + if (tool < 0 || tool > 255) return + this.tool = tool + } + + jumpTo(nx, ny, nz) { + if (nx === this.px && ny === this.py && nz === this.pz) return + this.pushSegment(FEATURE.NONE, 0, 0) + this.px = nx + this.py = ny + this.pz = nz + } + + linearMove(mask) { + if (mask & W_F && this.wF > 0) this.feed = (this.wF * this.unit) / 60 + const unit = this.unit + let nx = this.px + let ny = this.py + let nz = this.pz + if (this.absolute) { + if (mask & W_X && Number.isFinite(this.wX)) + nx = this.wX * unit + this.offX + if (mask & W_Y && Number.isFinite(this.wY)) + ny = this.wY * unit + this.offY + if (mask & W_Z && Number.isFinite(this.wZ)) + nz = this.wZ * unit + this.offZ + } else { + if (mask & W_X && Number.isFinite(this.wX)) nx += this.wX * unit + if (mask & W_Y && Number.isFinite(this.wY)) ny += this.wY * unit + if (mask & W_Z && Number.isFinite(this.wZ)) nz += this.wZ * unit + } + let ne = this.pe + if (mask & W_E && Number.isFinite(this.wE)) { + ne = this.absoluteE + ? this.wE * unit + this.offE + : this.pe + this.wE * unit + } + const de = ne - this.pe + if (nx === this.px && ny === this.py && nz === this.pz) { + this.pe = ne + return + } + this.emitMove(nx, ny, nz, de) + this.pe = ne + } + + arcMove(mask, clockwise) { + if (mask & W_F && this.wF > 0) this.feed = (this.wF * this.unit) / 60 + const unit = this.unit + const sx = this.px + const sy = this.py + const sz = this.pz + let ex = sx + let ey = sy + let ez = sz + if (this.absolute) { + if (mask & W_X && Number.isFinite(this.wX)) + ex = this.wX * unit + this.offX + if (mask & W_Y && Number.isFinite(this.wY)) + ey = this.wY * unit + this.offY + if (mask & W_Z && Number.isFinite(this.wZ)) + ez = this.wZ * unit + this.offZ + } else { + if (mask & W_X && Number.isFinite(this.wX)) ex += this.wX * unit + if (mask & W_Y && Number.isFinite(this.wY)) ey += this.wY * unit + if (mask & W_Z && Number.isFinite(this.wZ)) ez += this.wZ * unit + } + let ne = this.pe + if (mask & W_E && Number.isFinite(this.wE)) { + ne = this.absoluteE + ? this.wE * unit + this.offE + : this.pe + this.wE * unit + } + const de = ne - this.pe + + let cx + let cy + if (mask & (W_I | W_J)) { + cx = sx + (mask & W_I && Number.isFinite(this.wI) ? this.wI * unit : 0) + cy = sy + (mask & W_J && Number.isFinite(this.wJ) ? this.wJ * unit : 0) + } else if (mask & W_R && Number.isFinite(this.wR) && this.wR !== 0) { + const r = this.wR * unit + const dx = ex - sx + const dy = ey - sy + const d = Math.sqrt(dx * dx + dy * dy) + if (d === 0 || d > Math.abs(r) * 2 + 1e-6) { + this.emitMove(ex, ey, ez, de) + this.pe = ne + return + } + const h = Math.sqrt(Math.max(0, r * r - (d * d) / 4)) + const mx = (sx + ex) / 2 + const my = (sy + ey) / 2 + // Left-hand perpendicular of the chord. + const lx = -dy / d + const ly = dx / d + let side = clockwise ? -1 : 1 + if (r < 0) side = -side + cx = mx + lx * h * side + cy = my + ly * h * side + } else { + this.emitMove(ex, ey, ez, de) + this.pe = ne + return + } + + const radius = Math.sqrt((sx - cx) * (sx - cx) + (sy - cy) * (sy - cy)) + if (!(radius > 0)) { + this.emitMove(ex, ey, ez, de) + this.pe = ne + return + } + const a0 = Math.atan2(sy - cy, sx - cx) + const a1 = Math.atan2(ey - cy, ex - cx) + let sweep = a1 - a0 + const fullCircle = Math.abs(ex - sx) < 1e-9 && Math.abs(ey - sy) < 1e-9 + if (clockwise) { + if (sweep >= -1e-9 || fullCircle) sweep -= Math.PI * 2 + } else if (sweep <= 1e-9 || fullCircle) { + sweep += Math.PI * 2 + } + // Slicers round coordinates to ~1 µm, which can flip the sign of a very + // short arc and turn it into a full circle. Treat those as the short arc. + if (!fullCircle && Math.abs(sweep) > Math.PI * 2 - 0.02) { + sweep = a1 - a0 + if (sweep > Math.PI) sweep -= Math.PI * 2 + if (sweep < -Math.PI) sweep += Math.PI * 2 + } + const tol = Math.min(ARC_TOLERANCE, radius * 0.5) + const stepAngle = 2 * Math.acos(1 - tol / radius) + let steps = Math.ceil(Math.abs(sweep) / Math.max(stepAngle, 1e-3)) + if (steps < 1) steps = 1 + if (steps > 512) steps = 512 + + const dePerStep = de / steps + const dz = (ez - sz) / steps + for (let k = 1; k <= steps; k++) { + const a = a0 + (sweep * k) / steps + const x = k === steps ? ex : cx + radius * Math.cos(a) + const y = k === steps ? ey : cy + radius * Math.sin(a) + const z = k === steps ? ez : sz + dz * k + this.emitMove(x, y, z, dePerStep) + } + this.pe = ne + } + + emitMove(nx, ny, nz, de) { + const extruding = de > 1e-7 + let type + let width = 0 + let height = 0 + if (extruding) { + type = this.currentType + this.trackLayerForExtrusion(nz) + height = + this.commentHeight > 0 ? this.commentHeight : this.estimatedLayerHeight + if (this.commentWidth > 0) { + width = this.commentWidth + } else { + const dx = nx - this.px + const dy = ny - this.py + const dz = nz - this.pz + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) + if (dist > 0 && height > 0) { + const area = (de * this.filamentArea) / dist + width = area / height + height * (1 - Math.PI / 4) + if (!(width > 0.05)) width = this.nozzleDiameter + if (width > 5) width = 5 + } else { + width = this.nozzleDiameter + } + } + this.layerHasExtrusion = true + } else { + type = this.wipeActive ? FEATURE.WIPE : FEATURE.TRAVEL + } + this.pushSegment(type, width, height) + this.px = nx + this.py = ny + this.pz = nz + } + + pushSegment(type, width, height) { + if (this.layerIndex < 0) this.beginLayer(NaN, false) + this.store.push( + this.px, + this.py, + this.pz, + type, + this.tool, + this.fan, + this.layerIndex, + this.temp, + width, + height, + this.feed, + this.accel > 0 ? this.accel : this.defaultAccel || DEFAULT_ACCEL, + this.lineOffset + ) + this.typeCounts[type]++ + if (isExtrusionFeature(type)) this.toolsUsed.add(this.tool) + } + + // --------------------------------------------------------------------- + // Layers + // --------------------------------------------------------------------- + + beginLayer(z, fromMarker) { + if (fromMarker) this.markerSeen = true + const current = this.layers[this.layerIndex] + if (current && !this.layerHasExtrusion) { + // Nothing was printed in the current layer yet (e.g. a marker that + // follows start G-code): reuse it instead of leaving an empty layer. + if (Number.isFinite(z)) current.z = z + this.layerZ = Number.isFinite(z) ? z : this.layerZ + return + } + if (current) { + current.end = this.store.count + this.prevLayerZ = Number.isFinite(current.z) ? current.z : this.prevLayerZ + } + this.layers.push({ z, start: this.store.count, end: -1, fromMarker }) + this.layerIndex = this.layers.length - 1 + this.layerZ = z + this.layerHasExtrusion = false + } + + trackLayerForExtrusion(z) { + const current = this.layers[this.layerIndex] + if (this.markerSeen) { + if (current && !Number.isFinite(current.z)) { + current.z = z + this.layerZ = z + this.updateLayerHeightEstimate(z) + } + return + } + if ( + !current || + !Number.isFinite(this.layerZ) || + Math.abs(z - this.layerZ) > 1e-4 + ) { + if (current && !Number.isFinite(current.z)) { + current.z = z + this.layerZ = z + } else { + this.beginLayer(z, false) + } + this.updateLayerHeightEstimate(z) + } + } + + updateLayerHeightEstimate(z) { + const prev = this.prevLayerZ + if (Number.isFinite(prev)) { + const h = z - prev + if (h > 0.02 && h < 1.5) this.estimatedLayerHeight = h + } else if (z > 0.02 && z < 1.5) { + this.estimatedLayerHeight = z + } + } + + // --------------------------------------------------------------------- + // Comments and metadata + // --------------------------------------------------------------------- + + handleComment(buf, s, end) { + while (s < end && (buf[s] === CH_SPACE || buf[s] === CH_TAB)) s++ + const length = end - s + if (length < 2 || length > 512) return + const text = this.decoder.decode(buf.subarray(s, end)) + if ( + !this.metadata.generator && + (text.startsWith('generated by ') || text.startsWith('generated with ')) + ) { + this.metadata.generator = text.trim() + return + } + const colon = text.indexOf(':') + const eq = text.indexOf('=') + + if (colon > 0 && (eq < 0 || colon < eq)) { + const key = text + .slice(0, colon) + .trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') + const value = text.slice(colon + 1).trim() + this.handleKeyValue(key, value) + return + } + if (eq > 0) { + const key = text.slice(0, eq).trim().toLowerCase() + const value = text.slice(eq + 1).trim() + this.handleSetting(key, value) + return + } + const word = text.trim().toUpperCase() + switch (word) { + case 'LAYER_CHANGE': + case 'CHANGE_LAYER': + this.statsLayerMarker = false + this.beginLayer(this.pendingZ(), true) + break + case 'WIPE_START': + this.wipeActive = true + break + case 'WIPE_END': + this.wipeActive = false + break + default: + break + } + } + + pendingZ() { + const z = this.commentZ + this.commentZ = NaN + return z + } + + handleKeyValue(key, value) { + switch (key) { + case 'TYPE': + case 'FEATURE': + this.currentType = featureFromSlicerString(value) + this.sawTypeComment = true + break + case 'WIDTH': + case 'LINE_WIDTH': { + const w = parseFloat(value) + this.commentWidth = w > 0 ? w : 0 + break + } + case 'HEIGHT': + case 'LAYER_HEIGHT': { + const h = parseFloat(value) + this.commentHeight = h > 0 ? h : 0 + if (h > 0 && this.metadata.layerHeight == null) { + this.metadata.layerHeight = h + } + break + } + case 'Z': + case 'Z_HEIGHT': { + const z = parseFloat(value) + if (Number.isFinite(z)) { + this.commentZ = z + // Orca/Prusa emit ;Z: after ;LAYER_CHANGE: patch the open layer. + const current = this.layers[this.layerIndex] + if (this.markerSeen && current && !Number.isFinite(current.z)) { + this.commentZ = NaN + const previous = this.layers[this.layerIndex - 1] + if ( + previous && + !previous.fromMarker && + Math.abs(previous.z - z) < 1e-4 + ) { + // A Z-detected layer (typically the purge line in start + // G-code) sits at the same height as the first slicer layer: + // fold the new marker layer back into it. + this.layers.pop() + this.layerIndex-- + previous.end = -1 + const layerField = this.store.layer + for (let i = current.start; i < this.store.count; i++) { + layerField[i] = this.layerIndex + } + this.layerHasExtrusion = true + this.layerZ = z + } else { + current.z = z + this.layerZ = z + } + } + } + break + } + case 'LAYER': + case 'LAYER_NUM/TOTAL_LAYER_COUNT': + this.statsLayerMarker = false + this.beginLayer(this.pendingZ(), true) + break + case 'LAYER_COUNT': + case 'TOTAL_LAYER_NUMBER': { + const total = parseInt(value, 10) + if (total > 0) this.metadata.totalLayers = total + break + } + case 'TIME': { + const seconds = parseFloat(value) + if (seconds > 0) this.metadata.estimatedTime = seconds + break + } + case 'MODEL_PRINTING_TIME': + case 'TOTAL_ESTIMATED_TIME': { + const seconds = parseDuration(value.split(';')[0]) + if (seconds > 0 && this.metadata.estimatedTime == null) { + this.metadata.estimatedTime = seconds + } + break + } + default: + break + } + } + + handleSetting(key, value) { + // Base64 thumbnail rows ending in '=' padding land here; ignore them. + if (key.length > 80 || /[^a-z0-9_ ()./-]/.test(key)) return + if (Object.keys(this.metadata.settings).length < 1500) { + this.metadata.settings[key] = + value.length > 500 ? value.slice(0, 500) : value + } + switch (key) { + case 'printable_area': + case 'bed_shape': + this.parseBedShape(value) + break + case 'printable_height': + case 'max_print_height': { + const z = parseFloat(value) + if (z > 0) { + const bv = + this.metadata.buildVolume || (this.metadata.buildVolume = {}) + bv.maxZ = z + } + break + } + case 'filament_colour': + case 'filament_color': + case 'extruder_colour': + case 'extruder_color': { + const colors = value + .split(';') + .map((c) => c.trim()) + .filter((c) => /^#[0-9a-f]{6}$/i.test(c)) + if ( + colors.length && + (key.startsWith('filament') || !this.metadata.filamentColors.length) + ) { + this.metadata.filamentColors = colors + } + break + } + case 'filament_diameter': { + const d = parseFloat(value) + if (d > 0) { + this.filamentDiameter = d + this.filamentArea = Math.PI * (d / 2) * (d / 2) + this.metadata.filamentDiameter = d + } + break + } + case 'nozzle_diameter': { + const d = parseFloat(value) + if (d > 0) { + this.nozzleDiameter = d + this.metadata.nozzleDiameter = d + } + break + } + case 'layer_height': { + const h = parseFloat(value) + if (h > 0) { + this.metadata.layerHeight = h + if (this.estimatedLayerHeight === DEFAULT_LAYER_HEIGHT) { + this.estimatedLayerHeight = h + } + } + break + } + case 'default_acceleration': { + const a = parseFloat(value) + if (a > 0) this.defaultAccel = a + break + } + case 'machine_max_acceleration_extruding': { + const a = parseFloat(value) + if (a > 0 && !this.defaultAccel) this.defaultAccel = a + break + } + case 'machine_max_speed_x': + case 'machine_max_feedrate_x': { + const v = parseFloat(value) + if (v > 0 && !this.maxVelocity) this.maxVelocity = v + break + } + case 'estimated printing time (normal mode)': { + const seconds = parseDuration(value) + if (seconds > 0) this.metadata.estimatedTime = seconds + break + } + default: + break + } + } + + parseBedShape(value) { + const points = value.split(',') + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const point of points) { + const [xs, ys] = point.trim().split('x') + const x = parseFloat(xs) + const y = parseFloat(ys) + if (!Number.isFinite(x) || !Number.isFinite(y)) continue + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + if (maxX > minX && maxY > minY) { + const bv = this.metadata.buildVolume || (this.metadata.buildVolume = {}) + bv.minX = minX + bv.maxX = maxX + bv.minY = minY + bv.maxY = maxY + } + } + + // --------------------------------------------------------------------- + // Streaming + // --------------------------------------------------------------------- + + flushChunk(force) { + const store = this.store + const pending = store.count - this.sentCount + if (pending <= 0) return + const now = performance.now() + if ( + !force && + pending < CHUNK_SEGMENTS && + now - this.lastPostTime < CHUNK_INTERVAL_MS + ) { + return + } + // The last segment in the chunk ends at the current position, which is + // not written to the shared polyline until the next segment is pushed. + store.setEnd(this.px, this.py, this.pz) + const { chunk, transfer } = store.sliceChunk(this.sentCount, store.count) + this.post({ type: 'chunk', chunk }, transfer) + this.sentCount = store.count + this.lastPostTime = now + } + + finalize(byteLength) { + const store = this.store + store.setEnd(this.px, this.py, this.pz) + const current = this.layers[this.layerIndex] + if (current) current.end = store.count + if (current && !Number.isFinite(current.z)) current.z = this.pz + + const layers = this.layers + .filter((layer) => layer.end > layer.start) + .map((layer) => ({ + z: layer.z, + start: layer.start, + end: layer.end + })) + // Re-index segment layers after dropping empty ones so attribute values + // match the layer array indices. + if (layers.length !== this.layers.length) { + const layerField = store.layer + for (let l = 0; l < layers.length; l++) { + const { start, end } = layers[l] + for (let i = start; i < end; i++) layerField[i] = l + } + } + let prevZ = 0 + for (const layer of layers) { + if (!Number.isFinite(layer.z)) layer.z = prevZ + layer.height = Math.max(0, layer.z - prevZ) + prevZ = layer.z + } + + const plannerOptions = { + maxVelocity: + this.maxVelocity > 0 ? this.maxVelocity : DEFAULT_MAX_VELOCITY, + squareCornerVelocity: + this.squareCornerVelocity > 0 ? this.squareCornerVelocity : DEFAULT_SCV, + defaultAccel: this.defaultAccel > 0 ? this.defaultAccel : DEFAULT_ACCEL + } + const { actualSpeed, segmentTime } = planMotion(store, plannerOptions) + const { layerTime, perLayer, total } = computeLayerTimes( + store, + segmentTime, + layers + ) + for (let l = 0; l < layers.length; l++) layers[l].time = perLayer[l] + + const stats = this.computeStats(actualSpeed, layerTime) + const bounds = this.computeBounds() + + this.post( + { + type: 'done', + count: store.count, + end: [this.px, this.py, this.pz], + actualSpeed, + layerTime, + layers, + metadata: this.metadata, + stats, + bounds, + tools: Array.from(this.toolsUsed).sort((a, b) => a - b), + typeCounts: Array.from(this.typeCounts), + totalTime: total, + byteLength, + lineCount: this.lineCount, + hasTypeComments: this.sawTypeComment, + planner: plannerOptions + }, + [actualSpeed.buffer, layerTime.buffer] + ) + } + + computeStats(actualSpeed, layerTime) { + const store = this.store + const n = store.count + const type = store.type + const names = [ + 'height', + 'width', + 'feed', + 'actualSpeed', + 'accel', + 'fan', + 'temp', + 'flow', + 'actualFlow', + 'layerTime' + ] + const min = {} + const max = {} + for (const name of names) { + min[name] = Infinity + max[name] = -Infinity + } + const upd = (name, v) => { + if (v < min[name]) min[name] = v + if (v > max[name]) max[name] = v + } + for (let i = 0; i < n; i++) { + if (!isExtrusionFeature(type[i])) continue + const w = store.width[i] + const h = store.height[i] + const f = store.feed[i] + const a = actualSpeed[i] + upd('height', h) + upd('width', w) + upd('feed', f) + upd('actualSpeed', a) + upd('accel', store.accel[i]) + upd('fan', (store.fan[i] * 100) / 255) + upd('temp', store.temp[i]) + upd('flow', w * h * f) + upd('actualFlow', w * h * a) + upd('layerTime', layerTime[i]) + } + const stats = {} + for (const name of names) { + stats[name] = + min[name] <= max[name] ? { min: min[name], max: max[name] } : null + } + return stats + } + + computeBounds() { + const store = this.store + const n = store.count + const positions = store.positions + const type = store.type + let minX = Infinity + let minY = Infinity + let minZ = Infinity + let maxX = -Infinity + let maxY = -Infinity + let maxZ = -Infinity + for (let i = 0; i < n; i++) { + if (!isExtrusionFeature(type[i])) continue + for (let k = 0; k < 2; k++) { + const p = (i + k) * 3 + const x = positions[p] + const y = positions[p + 1] + const z = positions[p + 2] + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + } + if (!(minX <= maxX)) return null + return { minX, minY, minZ, maxX, maxY, maxZ } + } +} + +function parseDuration(text) { + if (!text) return 0 + let seconds = 0 + const re = /(\d+(?:\.\d+)?)\s*([dhms])/gi + let match + let any = false + while ((match = re.exec(text)) !== null) { + any = true + const v = parseFloat(match[1]) + switch (match[2].toLowerCase()) { + case 'd': + seconds += v * 86400 + break + case 'h': + seconds += v * 3600 + break + case 'm': + seconds += v * 60 + break + default: + seconds += v + } + } + return any ? seconds : 0 +} + +async function load(src, expectedSize) { + const post = (message, transfer) => self.postMessage(message, transfer || []) + const response = await fetch(src) + if (!response.ok || !response.body) { + throw new Error(`Failed to fetch G-code (${response.status})`) + } + const headerSize = Number(response.headers.get('content-length')) + const size = headerSize > 0 ? headerSize : expectedSize > 0 ? expectedSize : 0 + post({ type: 'start', size }) + + const parser = new GCodeParser(post, size) + const reader = response.body.getReader() + let leftover = null + let offset = 0 + let bytesRead = 0 + let lastProgress = 0 + + for (;;) { + const { done, value } = await reader.read() + if (done) break + let buf = value + let i = 0 + bytesRead += buf.length + + if (leftover) { + let nl = buf.indexOf(CH_LF) + if (nl < 0) { + const merged = new Uint8Array(leftover.length + buf.length) + merged.set(leftover) + merged.set(buf, leftover.length) + leftover = merged + continue + } + const merged = new Uint8Array(leftover.length + nl) + merged.set(leftover) + merged.set(buf.subarray(0, nl), leftover.length) + parser.parseLine(merged, 0, merged.length, offset) + offset += merged.length + 1 + i = nl + 1 + leftover = null + } + + for (;;) { + const nl = buf.indexOf(CH_LF, i) + if (nl < 0) break + parser.parseLine(buf, i, nl, offset) + offset += nl - i + 1 + i = nl + 1 + } + if (i < buf.length) { + leftover = buf.slice(i) + } + + parser.flushChunk(false) + const now = performance.now() + if (now - lastProgress > 100) { + lastProgress = now + post({ + type: 'progress', + progress: size > 0 ? Math.min(1, bytesRead / size) : 0, + bytes: bytesRead, + segments: parser.store.count + }) + } + } + + if (leftover && leftover.length) { + parser.parseLine(leftover, 0, leftover.length, offset) + offset += leftover.length + } + + parser.flushChunk(true) + post({ + type: 'progress', + progress: 1, + bytes: bytesRead, + segments: parser.store.count, + planning: true + }) + parser.finalize(bytesRead) +} + +self.onmessage = (event) => { + const { type, src, size } = event.data || {} + if (type !== 'load') return + load(src, size).catch((error) => { + self.postMessage({ + type: 'error', + message: error?.message || String(error) + }) + }) +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/planner.js b/src/components/Components/GCodePreview/GCodeViewer/planner.js new file mode 100644 index 00000000..335b9df5 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/planner.js @@ -0,0 +1,169 @@ +import { FEATURE } from './featureTypes.js' + +// Simplified Klipper-style motion planner. For every segment it computes the +// trapezoid the firmware would run given the requested feedrate, the active +// acceleration and the junction limits between consecutive moves, which yields +// the speed the toolhead actually reaches and how long each segment takes. +// +// It ignores extruder-specific limits, minimum cruise ratio smoothing and +// input shaper, so times are approximate but relative values are faithful. + +const SQRT2_MINUS_1 = Math.SQRT2 - 1 + +export function planMotion(store, options = {}) { + const { + maxVelocity = 500, + squareCornerVelocity = 5, + defaultAccel = 3000 + } = options + + const n = store.count + const positions = store.positions + const feed = store.feed + const accelField = store.accel + const type = store.type + + const distance = new Float32Array(n) + const deltaV2 = new Float32Array(n) + const maxStartV2 = new Float32Array(n) + const maxCruiseV2 = new Float32Array(n) + const startV2 = new Float32Array(n) + const actualSpeed = new Float32Array(n) + const segmentTime = new Float32Array(n) + + const scv2 = squareCornerVelocity * squareCornerVelocity + + let prevValid = false + let prevDx = 0 + let prevDy = 0 + let prevDz = 0 + let prevAccel = defaultAccel + let prevJunctionDeviation = 0 + + for (let i = 0; i < n; i++) { + const p = i * 3 + const dx = positions[p + 3] - positions[p] + const dy = positions[p + 4] - positions[p + 1] + const dz = positions[p + 5] - positions[p + 2] + const d = Math.sqrt(dx * dx + dy * dy + dz * dz) + distance[i] = d + + if (type[i] === FEATURE.NONE || d <= 0) { + // Position jump: no motion, acts as a full stop. + deltaV2[i] = 0 + maxStartV2[i] = 0 + maxCruiseV2[i] = 0 + prevValid = false + continue + } + + let accel = accelField[i] + if (!(accel > 0)) accel = defaultAccel + let vReq = feed[i] + if (!(vReq > 0)) vReq = maxVelocity + if (vReq > maxVelocity) vReq = maxVelocity + const cruiseV2 = vReq * vReq + maxCruiseV2[i] = cruiseV2 + deltaV2[i] = 2 * d * accel + const junctionDeviation = (scv2 * SQRT2_MINUS_1) / accel + + const ux = dx / d + const uy = dy / d + const uz = dz / d + + if (!prevValid) { + maxStartV2[i] = 0 + } else { + let cosTheta = -(ux * prevDx + uy * prevDy + uz * prevDz) + if (cosTheta > 0.999999) { + maxStartV2[i] = 0 + } else { + if (cosTheta < -0.999999) cosTheta = -0.999999 + const sinThetaD2 = Math.sqrt(0.5 * (1 - cosTheta)) + const rJd = sinThetaD2 / (1 - sinThetaD2) + let limit = rJd * junctionDeviation * accel + const prevLimit = rJd * prevJunctionDeviation * prevAccel + if (prevLimit < limit) limit = prevLimit + if (cruiseV2 < limit) limit = cruiseV2 + const prevCruise = maxCruiseV2[i - 1] + if (prevCruise < limit) limit = prevCruise + const reachable = maxStartV2[i - 1] + deltaV2[i - 1] + if (reachable < limit) limit = reachable + maxStartV2[i] = limit + } + } + + prevValid = true + prevDx = ux + prevDy = uy + prevDz = uz + prevAccel = accel + prevJunctionDeviation = junctionDeviation + } + + // Backward pass: nothing may end faster than it can decelerate into the + // next junction (the file ends at rest). + let nextStartV2 = 0 + for (let i = n - 1; i >= 0; i--) { + const reachable = nextStartV2 + deltaV2[i] + const v2 = maxStartV2[i] < reachable ? maxStartV2[i] : reachable + startV2[i] = v2 + nextStartV2 = v2 + } + + // Forward pass and trapezoid generation. + let currentStartV2 = 0 + for (let i = 0; i < n; i++) { + const d = distance[i] + if (type[i] === FEATURE.NONE || d <= 0) { + currentStartV2 = 0 + continue + } + let sV2 = startV2[i] < currentStartV2 ? startV2[i] : currentStartV2 + const nextS = i + 1 < n ? startV2[i + 1] : 0 + const reachableEnd = sV2 + deltaV2[i] + let eV2 = nextS < reachableEnd ? nextS : reachableEnd + if (eV2 < 0) eV2 = 0 + let cV2 = (sV2 + eV2 + deltaV2[i]) * 0.5 + if (cV2 > maxCruiseV2[i]) cV2 = maxCruiseV2[i] + if (cV2 < sV2) cV2 = sV2 + if (cV2 < eV2) cV2 = eV2 + + let accel = accelField[i] + if (!(accel > 0)) accel = defaultAccel + const sV = Math.sqrt(sV2) + const eV = Math.sqrt(eV2) + const cV = Math.sqrt(cV2) + const halfInvAccel = 0.5 / accel + const accelD = (cV2 - sV2) * halfInvAccel + const decelD = (cV2 - eV2) * halfInvAccel + let cruiseD = d - accelD - decelD + if (cruiseD < 0) cruiseD = 0 + const t = + (cV - sV) / accel + (cV > 0 ? cruiseD / cV : 0) + (cV - eV) / accel + + actualSpeed[i] = cV + segmentTime[i] = t + currentStartV2 = eV2 + } + + return { actualSpeed, segmentTime } +} + +// Sums segment times per layer and expands them back to one value per +// segment so the shader can colour by layer time with a plain attribute. +export function computeLayerTimes(store, segmentTime, layers) { + const n = store.count + const layerTime = new Float32Array(n) + const perLayer = new Float32Array(layers.length) + let total = 0 + for (let l = 0; l < layers.length; l++) { + const { start, end } = layers[l] + let sum = 0 + for (let i = start; i < end && i < n; i++) sum += segmentTime[i] + perLayer[l] = sum + total += sum + for (let i = start; i < end && i < n; i++) layerTime[i] = sum + } + return { layerTime, perLayer, total } +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js b/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js new file mode 100644 index 00000000..4f910777 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js @@ -0,0 +1,179 @@ +// Structure-of-arrays storage for parsed G-code segments. +// +// Segment i runs from positions[i] to positions[i + 1], so the path is a +// single shared polyline and every other field is one entry per segment. The +// same class is used by the parser worker (filling it) and the main thread +// (receiving transferred chunks and feeding GPU buffers). + +export const SEGMENT_FIELDS = [ + ['type', Uint8Array], + ['tool', Uint8Array], + ['fan', Uint8Array], + ['layer', Uint16Array], + ['temp', Uint16Array], + ['width', Float32Array], + ['height', Float32Array], + ['feed', Float32Array], + ['accel', Float32Array], + ['offset', Uint32Array] +] + +// Filled after the planner pass, not part of streamed chunks. +export const PLANNER_FIELDS = [ + ['actualSpeed', Float32Array], + ['layerTime', Float32Array] +] + +const ALL_FIELDS = [...SEGMENT_FIELDS, ...PLANNER_FIELDS] + +export default class SegmentStore { + constructor(capacity = 1024) { + this.count = 0 + this.capacity = 0 + // Bumped whenever the backing arrays are reallocated so GPU-side + // consumers know to rebuild their attribute bindings. + this.generation = 0 + this.positions = null + this.plannerReady = false + this.allocate(Math.max(16, capacity | 0)) + } + + allocate(capacity) { + const oldCount = this.count + const oldPositions = this.positions + const nextPositions = new Float32Array((capacity + 1) * 3) + if (oldPositions) { + nextPositions.set(oldPositions.subarray(0, (oldCount + 1) * 3)) + } + this.positions = nextPositions + for (const [name, Type] of ALL_FIELDS) { + const next = new Type(capacity) + if (this[name]) next.set(this[name].subarray(0, oldCount)) + this[name] = next + } + this.capacity = capacity + this.generation++ + } + + ensureCapacity(required) { + if (required <= this.capacity) return + let next = this.capacity + while (next < required) next = Math.ceil(next * 1.5) + 1024 + this.allocate(next) + } + + // Worker side: append one segment. The start point is written here and the + // end point is whatever the next push (or setEnd) writes. + push( + x, + y, + z, + type, + tool, + fan, + layer, + temp, + width, + height, + feed, + accel, + offset + ) { + const i = this.count + if (i + 1 > this.capacity) this.ensureCapacity(i + 1) + const p = i * 3 + this.positions[p] = x + this.positions[p + 1] = y + this.positions[p + 2] = z + this.type[i] = type + this.tool[i] = tool + this.fan[i] = fan + this.layer[i] = layer > 65535 ? 65535 : layer + this.temp[i] = temp < 0 ? 0 : temp > 65535 ? 65535 : temp + this.width[i] = width + this.height[i] = height + this.feed[i] = feed + this.accel[i] = accel + this.offset[i] = offset + this.count = i + 1 + return i + } + + setEnd(x, y, z) { + const p = this.count * 3 + this.positions[p] = x + this.positions[p + 1] = y + this.positions[p + 2] = z + } + + // Worker side: copy segments [from, to) into transferable buffers. + sliceChunk(from, to) { + const count = to - from + const chunk = { start: from, count } + const transfer = [] + // Start points for every segment in the range plus the end of the last + // one, which is just the next start (or the final end point). + const positions = this.positions.slice(from * 3, (to + 1) * 3) + chunk.positions = positions + transfer.push(positions.buffer) + for (const [name] of SEGMENT_FIELDS) { + const copy = this[name].slice(from, to) + chunk[name] = copy + transfer.push(copy.buffer) + } + return { chunk, transfer } + } + + // Main thread side: append a chunk produced by sliceChunk. + appendChunk(chunk) { + const { start, count } = chunk + if (start !== this.count) { + throw new Error( + `SegmentStore chunk out of order (expected ${this.count}, got ${start})` + ) + } + this.ensureCapacity(start + count) + this.positions.set(chunk.positions, start * 3) + for (const [name] of SEGMENT_FIELDS) { + this[name].set(chunk[name], start) + } + this.count = start + count + } + + applyPlanner({ actualSpeed, layerTime }) { + if (actualSpeed) this.actualSpeed.set(actualSpeed.subarray(0, this.count)) + if (layerTime) this.layerTime.set(layerTime.subarray(0, this.count)) + this.plannerReady = true + } + + getStart(i, out) { + const p = i * 3 + out[0] = this.positions[p] + out[1] = this.positions[p + 1] + out[2] = this.positions[p + 2] + return out + } + + getEnd(i, out) { + return this.getStart(i + 1, out) + } + + // Index of the last segment whose source line starts at or before the given + // byte offset, or -1 when the offset precedes every segment. + segmentAtByteOffset(byteOffset) { + const offsets = this.offset + let lo = 0 + let hi = this.count - 1 + let result = -1 + while (lo <= hi) { + const mid = (lo + hi) >>> 1 + if (offsets[mid] <= byteOffset) { + result = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return result + } +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/tracking.js b/src/components/Components/GCodePreview/GCodeViewer/tracking.js new file mode 100644 index 00000000..5d2f77c3 --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/tracking.js @@ -0,0 +1,102 @@ +// Maps live printer state onto a segment index for progress rendering. + +const REFINE_WINDOW = 400 +const MAX_SNAP_DISTANCE = 3 // mm + +// Klipper reports virtual_sdcard.file_position in bytes. Our offsets are +// bytes into the same file, so this is exact unless the printer's copy has +// a different size, in which case we fall back to proportional scaling. +export function fileOffsetForPrinter( + filePosition, + printerFileSize, + byteLength +) { + if (!(filePosition >= 0)) return 0 + if ( + printerFileSize > 0 && + byteLength > 0 && + Math.abs(printerFileSize - byteLength) > 64 + ) { + return (filePosition / printerFileSize) * byteLength + } + return filePosition +} + +// Klipper's lookahead means file_position runs ahead of physical motion. +// Scan back from the file-derived segment for the segment closest to the +// live toolhead position and return where along it the head is. +export function refineSegmentByPosition(store, segment, position) { + const count = store.count + if (count === 0) return { segment: -1, t: 1 } + let seg = Math.min(Math.max(segment, 0), count - 1) + if (!position || position.length < 3) return { segment: seg, t: 1 } + const [px, py, pz] = position + if (![px, py, pz].every(Number.isFinite)) return { segment: seg, t: 1 } + + const positions = store.positions + const from = Math.max(0, seg - REFINE_WINDOW) + let bestSeg = -1 + let bestT = 1 + let bestD2 = Infinity + for (let i = seg; i >= from; i--) { + const p = i * 3 + const ax = positions[p] + const ay = positions[p + 1] + const az = positions[p + 2] + const bx = positions[p + 3] + const by = positions[p + 4] + const bz = positions[p + 5] + const dx = bx - ax + const dy = by - ay + const dz = bz - az + const len2 = dx * dx + dy * dy + dz * dz + let t = 0 + if (len2 > 1e-12) { + t = ((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / len2 + if (t < 0) t = 0 + else if (t > 1) t = 1 + } + const cx = ax + dx * t - px + const cy = ay + dy * t - py + const cz = az + dz * t - pz + const d2 = cx * cx + cy * cy + cz * cz + if (d2 < bestD2) { + bestD2 = d2 + bestSeg = i + bestT = t + if (d2 < 1e-6) break + } + } + if (bestSeg < 0 || bestD2 > MAX_SNAP_DISTANCE * MAX_SNAP_DISTANCE) { + return { segment: seg, t: 1 } + } + return { segment: bestSeg, t: bestT } +} + +export function pointOnSegment(store, segment, t, out = [0, 0, 0]) { + const count = store.count + if (count === 0) return null + const i = Math.min(Math.max(segment, 0), count - 1) + const p = i * 3 + const positions = store.positions + out[0] = positions[p] + (positions[p + 3] - positions[p]) * t + out[1] = positions[p + 1] + (positions[p + 4] - positions[p + 1]) * t + out[2] = positions[p + 2] + (positions[p + 5] - positions[p + 2]) * t + return out +} + +export function layerIndexForSegment(layers, segment) { + let lo = 0 + let hi = layers.length - 1 + let result = -1 + while (lo <= hi) { + const mid = (lo + hi) >>> 1 + if (layers[mid].start <= segment) { + result = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return result +} diff --git a/src/components/Components/GCodePreview/GCodeViewer/useGCodeLoader.js b/src/components/Components/GCodePreview/GCodeViewer/useGCodeLoader.js new file mode 100644 index 00000000..e28d6c4b --- /dev/null +++ b/src/components/Components/GCodePreview/GCodeViewer/useGCodeLoader.js @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react' +import SegmentStore from './segmentStore.js' + +const IDLE = { + status: 'idle', + progress: 0, + planning: false, + model: null, + revision: 0, + error: null +} + +export function createModel(size) { + const capacity = size > 0 ? Math.ceil(size / 28) : 65536 + return { + size, + store: new SegmentStore(Math.min(capacity, 50_000_000)), + layers: [], + metadata: null, + stats: null, + bounds: null, + tools: [], + typeCounts: null, + totalTime: 0, + byteLength: 0, + lineCount: 0, + hasTypeComments: false, + done: false + } +} + +// Streams a G-code file through the parser worker. `model` is a stable +// object per load that is mutated as chunks arrive; `revision` increments +// on every change so consumers can resync without deep comparisons. +export default function useGCodeLoader(src) { + const [state, setState] = useState(IDLE) + + useEffect(() => { + if (!src) { + setState(IDLE) + return undefined + } + + let cancelled = false + const worker = new Worker(new URL('./parser.worker.js', import.meta.url), { + type: 'module' + }) + let model = createModel(0) + setState({ + status: 'loading', + progress: 0, + planning: false, + model, + revision: 1, + error: null + }) + + worker.onmessage = (event) => { + if (cancelled) return + const message = event.data + switch (message.type) { + case 'start': { + if (message.size > 0 && model.store.count === 0) { + model = createModel(message.size) + setState((prev) => ({ + ...prev, + model, + revision: prev.revision + 1 + })) + } + break + } + case 'progress': { + setState((prev) => ({ + ...prev, + progress: message.progress, + planning: Boolean(message.planning) + })) + break + } + case 'chunk': { + model.store.appendChunk(message.chunk) + setState((prev) => ({ ...prev, revision: prev.revision + 1 })) + break + } + case 'done': { + const { store } = model + store.setEnd(message.end[0], message.end[1], message.end[2]) + store.applyPlanner(message) + model.layers = message.layers + model.metadata = message.metadata + model.stats = message.stats + model.bounds = message.bounds + model.tools = message.tools + model.typeCounts = message.typeCounts + model.totalTime = message.totalTime + model.byteLength = message.byteLength + model.lineCount = message.lineCount + model.hasTypeComments = message.hasTypeComments + model.done = true + setState((prev) => ({ + ...prev, + status: 'ready', + progress: 1, + planning: false, + revision: prev.revision + 1 + })) + worker.terminate() + break + } + case 'error': { + setState((prev) => ({ + ...prev, + status: 'error', + planning: false, + error: message.message || 'Failed to parse G-code' + })) + worker.terminate() + break + } + default: + break + } + } + worker.onerror = (event) => { + if (cancelled) return + setState((prev) => ({ + ...prev, + status: 'error', + error: event?.message || 'G-code worker failed' + })) + } + + worker.postMessage({ type: 'load', src }) + + return () => { + cancelled = true + worker.terminate() + } + }, [src]) + + return state +} diff --git a/src/components/Dashboard/common/FilePreview.jsx b/src/components/Dashboard/common/FilePreview.jsx index 39ca131d..5700e2de 100644 --- a/src/components/Dashboard/common/FilePreview.jsx +++ b/src/components/Dashboard/common/FilePreview.jsx @@ -9,7 +9,7 @@ import { memo, useRef } from 'react' -import GCodePreview from './GCodePreview' +import GCodePreview from './GCodeViewer' import ThreeDPreview from './ThreeDPreview' import PDFPreview from './PDFPreview' import ProgressDisplay from './ProgressDisplay' @@ -111,13 +111,7 @@ const FilePreview = ({ file, style = {} }) => { if (isGcode && fileObjectUrl) { return ( - + ) } diff --git a/src/components/Dashboard/common/GCodePreview.jsx b/src/components/Dashboard/common/GCodePreview.jsx deleted file mode 100644 index c81db6a1..00000000 --- a/src/components/Dashboard/common/GCodePreview.jsx +++ /dev/null @@ -1,526 +0,0 @@ -import { GCodePreview } from 'gcode-preview' -import PropTypes from 'prop-types' -import { Button, Card, Flex, Select, Slider } from 'antd' -import { useCallback, useEffect, useRef, useState } from 'react' -import * as THREE from 'three' -import PlusIcon from '../../Icons/PlusIcon.jsx' -import MinusIcon from '../../Icons/MinusIcon.jsx' -import PanIcon from '../../Icons/PanIcon.jsx' -import PanFilledIcon from '../../Icons/PanFilledIcon.jsx' -import KeyboardShortcut from './KeyboardShortcut.jsx' -import usePinchZoom, { - clampPreviewScale, - MAX_PREVIEW_SCALE, - MIN_PREVIEW_SCALE -} from '../hooks/usePinchZoom.js' -import { useThemeContext } from '../context/ThemeContext' - -function toCssColor(color, fallback) { - try { - return `#${new THREE.Color(color || fallback).getHexString()}` - } catch { - return fallback - } -} - -function getPreviewThemeColors({ - isDarkMode, - token, - topLayerColor, - lastSegmentColor -}) { - return { - backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff', - extrusionColor: token.colorPrimary, - travelColor: isDarkMode ? '#8c8c8c' : '#bfbfbf', - topLayerColor: topLayerColor || token.colorWarning, - lastSegmentColor: lastSegmentColor || token.colorPink - } -} - -function getCameraDistance(sceneManager) { - const distance = sceneManager.camera.position.distanceTo( - sceneManager.controls.target - ) - return Number.isFinite(distance) && distance > 0 ? distance : null -} - -function getCameraScale(sceneManager, baseDistance) { - const distance = getCameraDistance(sceneManager) - return distance == null ? null : clampPreviewScale(baseDistance / distance) -} - -function layerEndForGcodePosition(preview, gcodePosition) { - const layers = preview?.job?.layers - const count = layers?.length || preview?.countLayers || 0 - if (!count || !gcodePosition) return undefined - if (gcodePosition.isActive === false) return undefined - - const currentLayer = gcodePosition.currentLayer - if (typeof currentLayer === 'number' && currentLayer > 0) { - return Math.min(Math.round(currentLayer), count) - } - - const z = gcodePosition.position?.[2] - if (typeof z !== 'number' || !Number.isFinite(z) || !layers?.length) { - return undefined - } - - let index = -1 - for (let i = 0; i < layers.length; i++) { - if (layers[i].z <= z + 0.05) index = i - else break - } - return index < 0 ? 1 : index + 1 -} - -function applyCameraScale(sceneManager, baseDistance, scale) { - const { camera, controls } = sceneManager - const distance = getCameraDistance(sceneManager) - if (distance == null) return - - const nextDistance = baseDistance / scale - if (Math.abs(nextDistance - distance) <= distance * 1e-4) return - - const offset = camera.position.clone().sub(controls.target) - camera.position - .copy(controls.target) - .addScaledVector(offset, nextDistance / distance) - controls.update() -} - -function GCodePreviewUI(props) { - const { - src, - topLayerColor = '', - lastSegmentColor = '', - startLayer, - endLayer, - lineWidth, - style = {}, - showFollowCurrentSubJob = false, - gcodePosition = null - } = props - const { isDarkMode, themeConfig } = useThemeContext() - const { colorPrimary, colorWarning, colorPink } = themeConfig.token - const canvasRef = useRef(null) - const previewPaneRef = useRef(null) - const previewRef = useRef(null) - const baseDistanceRef = useRef(1) - const applyingScaleRef = useRef(false) - const [preview, setPreview] = useState(null) - const [previewScale, setPreviewScale] = useState(1) - const [panMode, setPanMode] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [renderMode, setRenderMode] = useState('lines') - const [travelVisibility, setTravelVisibility] = useState('hide') - const [followSelection, setFollowSelection] = useState('follow') - const renderModeRef = useRef(renderMode) - const travelVisibilityRef = useRef(travelVisibility) - const followRef = useRef({ - enabled: false, - gcodePosition: null, - endLayer - }) - renderModeRef.current = renderMode - travelVisibilityRef.current = travelVisibility - followRef.current = { - enabled: showFollowCurrentSubJob && followSelection === 'follow', - gcodePosition, - endLayer - } - - const applyFollowEndLayer = useCallback((instance) => { - if (!instance || previewRef.current !== instance) return - const sceneManager = instance.sceneManager - if (!sceneManager) return - const follow = followRef.current - const next = follow.enabled - ? layerEndForGcodePosition(instance, follow.gcodePosition) - : follow.endLayer - if (sceneManager.endLayer === next) return - sceneManager.endLayer = next - }, []) - - const resizePreview = useCallback(() => { - previewRef.current?.sceneManager?.resize() - }, []) - - useEffect(() => { - if (!canvasRef.current) return - - const themeColors = getPreviewThemeColors({ - isDarkMode, - token: { colorPrimary, colorWarning, colorPink }, - topLayerColor, - lastSegmentColor - }) - - previewRef.current?.dispose() - const instance = new GCodePreview({ - canvas: canvasRef.current, - startLayer, - endLayer, - lineWidth, - backgroundColor: toCssColor(themeColors.backgroundColor, '#ffffff'), - extrusionColor: toCssColor(themeColors.extrusionColor, '#0091ff'), - travelColor: toCssColor(themeColors.travelColor, '#8c8c8c'), - topLayerColor: toCssColor(themeColors.topLayerColor, '#ff9230'), - lastSegmentColor: toCssColor(themeColors.lastSegmentColor, '#ff69b4'), - buildVolume: { x: 250, y: 220, z: 150 }, - initialCameraPosition: [0, 400, 450], - droppable: false, - renderTubes: renderModeRef.current === 'pipes', - renderTravel: travelVisibilityRef.current === 'show' - }) - previewRef.current = instance - - const { camera, controls } = instance.sceneManager - baseDistanceRef.current = getCameraDistance(instance.sceneManager) || 1 - // The camera dollies instead of scaling pixels, so the frustum has to - // cover the full zoom range. - camera.far = Math.max( - camera.far, - (baseDistanceRef.current / MIN_PREVIEW_SCALE) * 2 - ) - camera.updateProjectionMatrix() - controls.minDistance = baseDistanceRef.current / MAX_PREVIEW_SCALE - controls.maxDistance = baseDistanceRef.current / MIN_PREVIEW_SCALE - - const handleControlsChange = () => { - if (applyingScaleRef.current) return - const scale = getCameraScale( - instance.sceneManager, - baseDistanceRef.current - ) - if (scale == null) return - setPreviewScale((prev) => (Math.abs(prev - scale) < 0.001 ? prev : scale)) - } - const handleControlsStart = () => setIsDragging(true) - const handleControlsEnd = () => setIsDragging(false) - - controls.addEventListener('change', handleControlsChange) - controls.addEventListener('start', handleControlsStart) - controls.addEventListener('end', handleControlsEnd) - - instance.onJobUpdated = () => { - applyFollowEndLayer(instance) - } - instance.sceneManager.resize() - setPreview(instance) - - const pane = previewPaneRef.current - const resizeObserver = - pane && typeof ResizeObserver === 'function' - ? new ResizeObserver(resizePreview) - : null - resizeObserver?.observe(pane) - window.addEventListener('resize', resizePreview) - - return () => { - instance.onJobUpdated = undefined - resizeObserver?.disconnect() - window.removeEventListener('resize', resizePreview) - controls.removeEventListener('change', handleControlsChange) - controls.removeEventListener('start', handleControlsStart) - controls.removeEventListener('end', handleControlsEnd) - setPreview(null) - setIsDragging(false) - instance.clear() - instance.dispose() - if (previewRef.current === instance) { - previewRef.current = null - } - } - }, [ - colorPink, - colorPrimary, - colorWarning, - endLayer, - isDarkMode, - lastSegmentColor, - lineWidth, - startLayer, - topLayerColor, - resizePreview, - applyFollowEndLayer - ]) - - useEffect(() => { - if (!preview) return - applyingScaleRef.current = true - applyCameraScale( - preview.sceneManager, - baseDistanceRef.current, - previewScale - ) - applyingScaleRef.current = false - }, [preview, previewScale]) - - useEffect(() => { - if (!preview) return - const { sceneManager } = preview - const renderTubes = renderMode === 'pipes' - const renderTravel = travelVisibility === 'show' - if ( - sceneManager.renderTubes === renderTubes && - sceneManager.renderTravel === renderTravel - ) { - return - } - sceneManager.renderTubes = renderTubes - sceneManager.renderTravel = renderTravel - sceneManager.render() - }, [preview, renderMode, travelVisibility]) - - useEffect(() => { - if (!preview) return - const { controls } = preview.sceneManager - controls.mouseButtons = { - LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE, - MIDDLE: THREE.MOUSE.DOLLY, - RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN - } - controls.touches = { - ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE, - TWO: THREE.TOUCH.DOLLY_PAN - } - }, [preview, panMode]) - - usePinchZoom({ - containerRef: previewPaneRef, - scale: previewScale, - onScaleChange: setPreviewScale, - enabled: preview != null - }) - - const resetView = useCallback(() => { - preview?.sceneManager.controls.reset() - setPreviewScale(1) - }, [preview]) - - useEffect(() => { - const abortController = new AbortController() - - const loadFromSrc = async () => { - const instance = previewRef.current - if (!src || !instance) return - try { - const response = await fetch(src, { signal: abortController.signal }) - if (!response.ok || !response.body) { - throw new Error(`Failed to load G-code (${response.status})`) - } - if (abortController.signal.aborted || previewRef.current !== instance) { - return - } - if (instance.countLayers > 0) { - instance.clear() - instance.sceneManager.startLayer = startLayer - instance.sceneManager.endLayer = endLayer - } - const stream = response.body.pipeThrough(new TextDecoderStream()) - await instance.processGCodeStream(stream) - if (abortController.signal.aborted || previewRef.current !== instance) { - return - } - if (typeof startLayer === 'number') { - instance.sceneManager.startLayer = startLayer - } - if (typeof endLayer === 'number') { - instance.sceneManager.endLayer = endLayer - } - applyFollowEndLayer(instance) - } catch (e) { - if (abortController.signal.aborted) return - console.error('Failed to load G-code from src', e) - } - } - loadFromSrc() - - return () => { - abortController.abort() - } - }, [ - colorPink, - colorPrimary, - colorWarning, - endLayer, - isDarkMode, - lastSegmentColor, - lineWidth, - src, - startLayer, - topLayerColor, - applyFollowEndLayer - ]) - - useEffect(() => { - applyFollowEndLayer(previewRef.current) - }, [ - applyFollowEndLayer, - followSelection, - gcodePosition, - preview, - showFollowCurrentSubJob - ]) - - return ( - - - { - setPanMode((prev) => !prev) - }} - > - -