Add GCode File Panel and Enhance Dashboard Printer Controls

- Introduced PrinterGCodeFilePanel component to display G-code file information and loading progress.
- Updated ControlPrinter component to include GCode File section with collapsible panel for G-code file management.
- Enhanced GCodePreview component to support following the current sub-job layer during G-code visualization.
- Added logic to handle G-code position updates and improve user experience during file loading and display.
This commit is contained in:
Tom Butcher 2026-11-07 23:17:02 +00:00
parent 12c5cd83f4
commit 608eba41cf
3 changed files with 320 additions and 4 deletions

View File

@ -22,11 +22,13 @@ import MultiMotionIcon from '../../../Icons/MultiMotionIcon.jsx'
import SettingsIcon from '../../../Icons/SettingsIcon.jsx'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel.jsx'
import PrinterGCodeFilePanel from '../../common/PrinterGCodeFilePanel.jsx'
import PrinterPositionPanel from '../../common/PrinterPositionPanel.jsx'
import PrinterMovementPanel from '../../common/PrinterMovementPanel.jsx'
import PrinterMiscPanel from '../../common/PrinterMiscPanel.jsx'
import JobIcon from '../../../Icons/JobIcon.jsx'
import SubJobIcon from '../../../Icons/SubJobIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon.jsx'
import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
import { useMediaQuery } from 'react-responsive'
@ -59,6 +61,7 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
printer: true,
job: true,
subjob: true,
gcodeFile: true,
queue: true,
filamentStock: true,
temperature: true,
@ -307,6 +310,10 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
key: 'subJob',
label: 'Sub Job'
},
{
key: 'gcodeFile',
label: 'GCode File'
},
{
key: 'queue',
label: 'Queue'
@ -553,6 +560,23 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
/>
)}
</InfoCollapse>
<InfoCollapse
title={'GCode File'}
icon={<GCodeFileIcon />}
collapseKey='gcodeFile'
active={collapseState.gcodeFile}
onToggle={(expanded) =>
updateCollapseState('gcodeFile', expanded)
}
>
<PrinterGCodeFilePanel
id={printerId}
subJobId={
objectFormState.objectData?.currentSubJob?._id
}
offline={!objectFormState.objectData?.online}
/>
</InfoCollapse>
<InfoCollapse
title={'Filament Stock'}
icon={<FilamentStockIcon />}

View File

@ -50,6 +50,30 @@ function getCameraScale(sceneManager, baseDistance) {
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)
@ -73,7 +97,9 @@ function GCodePreviewUI(props) {
startLayer,
endLayer,
lineWidth,
style = {}
style = {},
showFollowCurrentSubJob = false,
gcodePosition = null
} = props
const { isDarkMode, themeConfig } = useThemeContext()
const { colorPrimary, colorWarning, colorPink } = themeConfig.token
@ -88,10 +114,33 @@ function GCodePreviewUI(props) {
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()
@ -154,6 +203,9 @@ function GCodePreviewUI(props) {
controls.addEventListener('start', handleControlsStart)
controls.addEventListener('end', handleControlsEnd)
instance.onJobUpdated = () => {
applyFollowEndLayer(instance)
}
instance.sceneManager.resize()
setPreview(instance)
@ -166,6 +218,7 @@ function GCodePreviewUI(props) {
window.addEventListener('resize', resizePreview)
return () => {
instance.onJobUpdated = undefined
resizeObserver?.disconnect()
window.removeEventListener('resize', resizePreview)
controls.removeEventListener('change', handleControlsChange)
@ -189,7 +242,8 @@ function GCodePreviewUI(props) {
lineWidth,
startLayer,
topLayerColor,
resizePreview
resizePreview,
applyFollowEndLayer
])
useEffect(() => {
@ -275,6 +329,7 @@ function GCodePreviewUI(props) {
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)
@ -295,7 +350,18 @@ function GCodePreviewUI(props) {
lineWidth,
src,
startLayer,
topLayerColor
topLayerColor,
applyFollowEndLayer
])
useEffect(() => {
applyFollowEndLayer(previewRef.current)
}, [
applyFollowEndLayer,
followSelection,
gcodePosition,
preview,
showFollowCurrentSubJob
])
return (
@ -364,6 +430,18 @@ function GCodePreviewUI(props) {
popupMatchSelectWidth={false}
onChange={setTravelVisibility}
/>
{showFollowCurrentSubJob ? (
<Select
value={followSelection}
options={[
{ value: 'follow', label: 'Follow current sub job' },
{ value: 'file', label: 'Entire file' }
]}
style={{ minWidth: 190 }}
popupMatchSelectWidth={false}
onChange={setFollowSelection}
/>
) : null}
</Flex>
<div
@ -432,7 +510,17 @@ GCodePreviewUI.propTypes = {
startLayer: PropTypes.number,
endLayer: PropTypes.number,
lineWidth: PropTypes.number,
style: PropTypes.object
style: PropTypes.object,
showFollowCurrentSubJob: PropTypes.bool,
gcodePosition: PropTypes.shape({
position: PropTypes.arrayOf(PropTypes.number),
filePosition: PropTypes.number,
fileSize: PropTypes.number,
progress: PropTypes.number,
isActive: PropTypes.bool,
currentLayer: PropTypes.number,
totalLayer: PropTypes.number
})
}
export default GCodePreviewUI

View File

@ -0,0 +1,204 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { Card, Flex, Spin } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import merge from 'lodash/merge'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import GCodePreview from './GCodePreview'
import MissingPlaceholder from './MissingPlaceholder'
import ProgressDisplay from './ProgressDisplay'
const emptyGcodePosition = {
position: [0, 0, 0, 0],
filePosition: 0,
fileSize: 0,
progress: 0,
isActive: false,
currentLayer: 0,
totalLayer: 0
}
const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
const { subscribeToObjectEvent, connected, fetchObject, fetchFileContent } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const apiRef = useRef({ fetchObject, fetchFileContent })
apiRef.current = { fetchObject, fetchFileContent }
const objectUrlRef = useRef(null)
const [gcodePosition, setGcodePosition] = useState(emptyGcodePosition)
const [src, setSrc] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [downloadProgress, setDownloadProgress] = useState(null)
useEffect(() => {
if (offline) {
setGcodePosition(emptyGcodePosition)
}
}, [offline])
useEffect(() => {
if (id && connected == true && offline == false) {
const gcodePositionUnsubscribe = subscribeToObjectEvent(
id?.toLowerCase(),
'printer',
'gcodePosition',
(event) => {
setGcodePosition((prev) => merge({}, prev, event.data))
}
)
return () => {
if (gcodePositionUnsubscribe) gcodePositionUnsubscribe()
}
}
}, [id, connected, subscribeToObjectEvent, offline])
useEffect(() => {
let cancelled = false
const replaceSrc = (nextSrc) => {
if (objectUrlRef.current && objectUrlRef.current !== nextSrc) {
URL.revokeObjectURL(objectUrlRef.current)
}
objectUrlRef.current = nextSrc
setSrc(nextSrc)
}
const loadGcodeFile = async () => {
replaceSrc(null)
setError(null)
if (!subJobId || token == null) {
setLoading(false)
setDownloadProgress(null)
return
}
setLoading(true)
setDownloadProgress({ progress: 0, message: 'Loading G-code file...' })
const subJob = await apiRef.current.fetchObject(subJobId, 'subJob')
if (cancelled) return
const gcodeFileId = subJob?.gcodeFile?._id
if (!gcodeFileId) {
setLoading(false)
setDownloadProgress(null)
setError('No G-code file.')
return
}
const gcodeFile = await apiRef.current.fetchObject(
gcodeFileId,
'gcodeFile'
)
if (cancelled) return
const file = gcodeFile?.file
if (!file?._id) {
setLoading(false)
setDownloadProgress(null)
setError('No G-code file.')
return
}
const objectUrl = await apiRef.current.fetchFileContent(
file,
false,
(progress) => {
if (cancelled) return
setDownloadProgress(progress)
}
)
if (cancelled) {
if (objectUrl) URL.revokeObjectURL(objectUrl)
return
}
if (!objectUrl) {
setLoading(false)
setDownloadProgress(null)
setError('Failed to load G-code file.')
return
}
replaceSrc(objectUrl)
setDownloadProgress(null)
setLoading(false)
}
loadGcodeFile()
return () => {
cancelled = true
}
}, [subJobId, token])
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [])
if (!subJobId) {
return (
<MissingPlaceholder
message={'No sub job.'}
padding='24px'
hasBackground={false}
mutedBorder={true}
/>
)
}
if (error) {
return (
<MissingPlaceholder
message={error}
padding='24px'
hasBackground={false}
mutedBorder={true}
/>
)
}
return (
<Card>
{loading || !src ? (
<Flex align='center' justify='center' style={{ minHeight: 240 }}>
{downloadProgress ? (
<div style={{ width: '100%', maxWidth: 360 }}>
<ProgressDisplay
percent={Math.round(
(Number(downloadProgress.progress) || 0) * 100
)}
status='active'
>
{downloadProgress.message || 'Downloading...'}
</ProgressDisplay>
</div>
) : (
<Spin indicator={<LoadingOutlined spin />} size='large' />
)}
</Flex>
) : (
<GCodePreview
src={src}
startLayer={0}
endLayer={undefined}
lineWidth={1}
showFollowCurrentSubJob={true}
gcodePosition={gcodePosition}
style={{ width: '100%', height: '60vh' }}
/>
)}
</Card>
)
}
PrinterGCodeFilePanel.propTypes = {
id: PropTypes.string,
subJobId: PropTypes.string,
offline: PropTypes.bool
}
export default PrinterGCodeFilePanel