diff --git a/src/components/Dashboard/Production/Printers/ControlPrinter.jsx b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx
index 1bdc6fef..61075a15 100644
--- a/src/components/Dashboard/Production/Printers/ControlPrinter.jsx
+++ b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx
@@ -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 }) => {
/>
)}
+ }
+ collapseKey='gcodeFile'
+ active={collapseState.gcodeFile}
+ onToggle={(expanded) =>
+ updateCollapseState('gcodeFile', expanded)
+ }
+ >
+
+
}
diff --git a/src/components/Dashboard/common/GCodePreview.jsx b/src/components/Dashboard/common/GCodePreview.jsx
index 285b1d9c..c81db6a1 100644
--- a/src/components/Dashboard/common/GCodePreview.jsx
+++ b/src/components/Dashboard/common/GCodePreview.jsx
@@ -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 ? (
+
+ ) : null}
{
+ 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 (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {loading || !src ? (
+
+ {downloadProgress ? (
+
+
+ {downloadProgress.message || 'Downloading...'}
+
+
+ ) : (
+ } size='large' />
+ )}
+
+ ) : (
+
+ )}
+
+ )
+}
+
+PrinterGCodeFilePanel.propTypes = {
+ id: PropTypes.string,
+ subJobId: PropTypes.string,
+ offline: PropTypes.bool
+}
+
+export default PrinterGCodeFilePanel