Enhance GCodePreview and Tracking Logic for Improved Motion Handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Refactored GCodePreview component to integrate new tracking logic, allowing for smoother motion representation based on live printer state.
- Introduced sampleTime and updateIntervalMs props to manage motion updates more effectively.
- Updated tracking.js to include new functions for handling sample intervals and refining segment positions.
- Enhanced segmentStore and parser.worker.js to accommodate new segmentTime data for better performance tracking.
- Improved PrinterGCodeFilePanel to utilize the new tracking state, ensuring accurate representation of gcode and live positions during printing.
This commit is contained in:
Tom Butcher 2026-11-09 01:30:58 +00:00
parent 4a90675ef5
commit 0e9b6392c2
5 changed files with 551 additions and 91 deletions

View File

@ -27,10 +27,12 @@ import {
} from './GCodeViewer/colorModes.js' } from './GCodeViewer/colorModes.js'
import { ALL_TYPES_MASK, FEATURE } from './GCodeViewer/featureTypes.js' import { ALL_TYPES_MASK, FEATURE } from './GCodeViewer/featureTypes.js'
import { import {
fileOffsetForPrinter, buildPathSpan,
DEFAULT_UPDATE_INTERVAL_MS,
layerIndexForSegment, layerIndexForSegment,
pointOnSegment, pointOnSegment,
refineSegmentByPosition resolveTrackSample,
samplePathSpan
} from './GCodeViewer/tracking.js' } from './GCodeViewer/tracking.js'
const { Text } = Typography const { Text } = Typography
@ -82,16 +84,6 @@ function applyCameraScale(scene, baseDistance, scale) {
controls.update() 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) { function GCodePreview(props) {
const { const {
src, src,
@ -100,7 +92,12 @@ function GCodePreview(props) {
style = {}, style = {},
showFollowCurrentSubJob = false, showFollowCurrentSubJob = false,
gcodePosition = null, gcodePosition = null,
toolPosition = null toolPosition = null,
// performance.now() of the latest coalesced printer sample, and how long
// the previous samples were apart. The toolhead walks the G-code between
// them instead of jumping.
sampleTime = 0,
updateIntervalMs = 0
} = props } = props
const { isDarkMode } = useThemeContext() const { isDarkMode } = useThemeContext()
@ -313,36 +310,239 @@ function GCodePreview(props) {
// ---- progress: live tracking or scrubbing ------------------------------ // ---- progress: live tracking or scrubbing ------------------------------
const trackInputsRef = useRef({
gcodePosition: null,
toolPosition: null,
intervalMs: DEFAULT_UPDATE_INTERVAL_MS,
byteLength: 0
})
trackInputsRef.current = {
gcodePosition,
toolPosition,
intervalMs:
updateIntervalMs > 0 ? updateIntervalMs : DEFAULT_UPDATE_INTERVAL_MS,
byteLength: model?.byteLength || model?.size || 0,
sampleTime
}
const motionRef = useRef(null)
useEffect(() => { useEffect(() => {
if (!scene || !model) return if (!scene || !model || !tracking) {
motionRef.current = null
return undefined
}
const store = model.store
const segments = scene.segments
const scratch = [0, 0, 0]
const motion = {
from: null,
to: null,
span: null,
linear: null,
startedAt: 0,
duration: DEFAULT_UPDATE_INTERVAL_MS,
lastReceivedAt: 0,
applied: null
}
let frame = 0
let stopped = false
const readPose = (now) => {
const target = motion.to
if (!target) return null
const duration =
motion.duration > 1 ? motion.duration : DEFAULT_UPDATE_INTERVAL_MS
const u = Math.min(1, Math.max(0, (now - motion.startedAt) / duration))
if (u >= 1 || !motion.from) return target
if (motion.span) return samplePathSpan(motion.span, u, store, scratch)
if (motion.linear) {
const lin = motion.linear
return {
segment: target.segment,
t: target.t,
x: lin.x0 + (lin.x1 - lin.x0) * u,
y: lin.y0 + (lin.y1 - lin.y0) * u,
z: lin.z0 + (lin.z1 - lin.z0) * u,
mapped: false
}
}
return target
}
const applyPose = (pose) => {
if (!pose) {
scene.setHotEndVisible(false)
return
}
const previous = motion.applied
if (
previous &&
pose.segment === previous.segment &&
Math.abs(pose.t - previous.t) < 2e-4 &&
Math.abs(pose.x - previous.x) < 0.01 &&
Math.abs(pose.y - previous.y) < 0.01 &&
Math.abs(pose.z - previous.z) < 0.01
) {
return
}
motion.applied = {
segment: pose.segment,
t: pose.t,
x: pose.x,
y: pose.y,
z: pose.z
}
if (pose.segment >= 0) {
const t = pose.mapped === false ? 1 : pose.t
segments.setProgress(pose.segment + 1, t)
} else {
segments.setProgress(0, 1)
}
scene.setToolPosition(pose.x, pose.y, pose.z, true)
scene.setHotEndVisible(true)
}
const tick = (now) => {
frame = 0
if (stopped) return
const pose = readPose(now)
if (pose) applyPose(pose)
if (
(motion.span || motion.linear) &&
now - motion.startedAt < motion.duration
) {
frame = requestAnimationFrame(tick)
}
}
const kick = () => {
if (!frame && !stopped) frame = requestAnimationFrame(tick)
}
motion.commit = (receivedAt) => {
if (stopped) return
if (receivedAt > 0 && receivedAt === motion.lastReceivedAt) return
if (receivedAt > 0) motion.lastReceivedAt = receivedAt
const inputs = trackInputsRef.current
const sample = resolveTrackSample(
store,
inputs.byteLength,
inputs.gcodePosition,
inputs.toolPosition
)
if (!sample) {
motion.to = null
motion.span = null
motion.linear = null
motion.from = null
scene.setHotEndVisible(false)
return
}
const now = performance.now()
const displayed = readPose(now)
const interval =
inputs.intervalMs > 0
? inputs.intervalMs
: DEFAULT_UPDATE_INTERVAL_MS
let span = null
if (
displayed &&
displayed.mapped !== false &&
displayed.segment >= 0 &&
sample.mapped &&
sample.segment >= 0
) {
const dx = sample.x - displayed.x
const dy = sample.y - displayed.y
const dz = sample.z - displayed.z
const distanceSq = dx * dx + dy * dy + dz * dz
if (distanceSq < 0.02 * 0.02) return
const forward =
sample.segment > displayed.segment ||
(sample.segment === displayed.segment &&
sample.t >= displayed.t - 1e-4)
if (!forward) {
if (distanceSq < 0.75 * 0.75) return
} else {
span = buildPathSpan(
store,
displayed.segment,
displayed.t,
sample.segment,
sample.t
)
}
}
motion.to = sample
if (span) {
motion.linear = null
motion.from = displayed
motion.span = span
motion.startedAt = now
motion.duration = interval
kick()
return
}
if (displayed) {
const dx = sample.x - displayed.x
const dy = sample.y - displayed.y
const dz = sample.z - displayed.z
const distance = Math.hypot(dx, dy, dz)
const offPath = sample.mapped === false || displayed.mapped === false
if (offPath && distance >= 0.02 && distance <= 500) {
motion.span = null
motion.linear = {
x0: displayed.x,
y0: displayed.y,
z0: displayed.z,
x1: sample.x,
y1: sample.y,
z1: sample.z
}
motion.from = displayed
motion.startedAt = now
motion.duration = interval
kick()
return
}
}
motion.span = null
motion.linear = null
motion.from = null
applyPose(sample)
if (frame) {
cancelAnimationFrame(frame)
frame = 0
}
}
motionRef.current = motion
const initialTime = trackInputsRef.current.sampleTime
if (initialTime > 0) motion.commit(initialTime)
return () => {
stopped = true
if (motionRef.current === motion) motionRef.current = null
if (frame) cancelAnimationFrame(frame)
}
}, [scene, model, tracking])
useEffect(() => {
if (!tracking || !(sampleTime > 0)) return
motionRef.current?.commit(sampleTime)
}, [tracking, sampleTime, gcodePosition, toolPosition, updateIntervalMs])
useEffect(() => {
if (!scene || !model || tracking) return
const store = model.store const store = model.store
const segments = scene.segments const segments = scene.segments
if (tracking) { if (scrubSegment != null && ready) {
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))) const end = Math.max(0, Math.min(store.count, Math.round(scrubSegment)))
segments.setProgress(end, 1) segments.setProgress(end, 1)
if (end > 0) { if (end > 0) {
@ -357,16 +557,7 @@ function GCodePreview(props) {
scene.setHotEndVisible(false) scene.setHotEndVisible(false)
} }
scene.requestRender() scene.requestRender()
}, [ }, [scene, model, revision, ready, tracking, scrubSegment])
scene,
model,
revision,
ready,
tracking,
gcodePosition,
toolPosition,
scrubSegment
])
const scrubRef = useRef(scrubSegment) const scrubRef = useRef(scrubSegment)
scrubRef.current = scrubSegment scrubRef.current = scrubSegment
@ -483,20 +674,14 @@ function GCodePreview(props) {
const statsMoveIndex = useMemo(() => { const statsMoveIndex = useMemo(() => {
if (!ready || !model) return 0 if (!ready || !model) return 0
if (tracking && gcodePosition) { if (tracking && gcodePosition) {
const offset = fileOffsetForPrinter( const sample = resolveTrackSample(
gcodePosition.filePosition, model.store,
gcodePosition.fileSize, model.byteLength || model.size,
model.byteLength || model.size gcodePosition,
toolPosition
) )
const fileSegment = model.store.segmentAtByteOffset(offset) if (!sample || sample.segment < 0) return 0
if (fileSegment < 0) return 0 return sample.segment + 1
const live = isFinitePosition(toolPosition)
? toolPosition
: isFinitePosition(gcodePosition.position)
? gcodePosition.position
: null
const refined = refineSegmentByPosition(model.store, fileSegment, live)
return refined.segment + 1
} }
return scrubValue return scrubValue
// revision: segment store grows while chunks stream in during tracking. // revision: segment store grows while chunks stream in during tracking.
@ -819,7 +1004,9 @@ GCodePreview.propTypes = {
currentLayer: PropTypes.number, currentLayer: PropTypes.number,
totalLayer: PropTypes.number totalLayer: PropTypes.number
}), }),
toolPosition: PropTypes.arrayOf(PropTypes.number) toolPosition: PropTypes.arrayOf(PropTypes.number),
sampleTime: PropTypes.number,
updateIntervalMs: PropTypes.number
} }
export default GCodePreview export default GCodePreview

View File

@ -1094,6 +1094,7 @@ class GCodeParser {
count: store.count, count: store.count,
end: [this.px, this.py, this.pz], end: [this.px, this.py, this.pz],
actualSpeed, actualSpeed,
segmentTime,
layerTime, layerTime,
layers, layers,
metadata: this.metadata, metadata: this.metadata,
@ -1107,7 +1108,7 @@ class GCodeParser {
hasTypeComments: this.sawTypeComment, hasTypeComments: this.sawTypeComment,
planner: plannerOptions planner: plannerOptions
}, },
[actualSpeed.buffer, layerTime.buffer] [actualSpeed.buffer, segmentTime.buffer, layerTime.buffer]
) )
} }

View File

@ -21,6 +21,7 @@ export const SEGMENT_FIELDS = [
// Filled after the planner pass, not part of streamed chunks. // Filled after the planner pass, not part of streamed chunks.
export const PLANNER_FIELDS = [ export const PLANNER_FIELDS = [
['actualSpeed', Float32Array], ['actualSpeed', Float32Array],
['segmentTime', Float32Array],
['layerTime', Float32Array] ['layerTime', Float32Array]
] ]
@ -140,8 +141,9 @@ export default class SegmentStore {
this.count = start + count this.count = start + count
} }
applyPlanner({ actualSpeed, layerTime }) { applyPlanner({ actualSpeed, segmentTime, layerTime }) {
if (actualSpeed) this.actualSpeed.set(actualSpeed.subarray(0, this.count)) if (actualSpeed) this.actualSpeed.set(actualSpeed.subarray(0, this.count))
if (segmentTime) this.segmentTime.set(segmentTime.subarray(0, this.count))
if (layerTime) this.layerTime.set(layerTime.subarray(0, this.count)) if (layerTime) this.layerTime.set(layerTime.subarray(0, this.count))
this.plannerReady = true this.plannerReady = true
} }

View File

@ -1,8 +1,39 @@
// Maps live printer state onto a segment index for progress rendering. // Maps live printer state onto a segment index for progress rendering.
//
// The printer bridge publishes motion and virtual_sdcard state together on a
// 500ms timer. Between those samples the toolhead is moved along the parsed
// toolpath, with each segment's share of the gap taken from the planned move
// time (or the commanded feed before planning finishes).
import { FEATURE } from './featureTypes.js'
const REFINE_WINDOW = 400 const REFINE_WINDOW = 400
const MAX_SNAP_DISTANCE = 3 // mm const MAX_SNAP_DISTANCE = 3 // mm
// Prior matches farmcontrol-server's printer event interval. Measured gaps
// replace it once two samples have arrived.
export const DEFAULT_UPDATE_INTERVAL_MS = 500
const MIN_UPDATE_INTERVAL_MS = 200
const MAX_UPDATE_INTERVAL_MS = 1000
const MIN_SAMPLE_GAP_MS = 80
const MAX_SAMPLE_GAP_MS = 4000
const MAX_SPAN_SEGMENTS = 2500
const MAX_SPAN_LENGTH = 500 // mm
const FALLBACK_SPEED = 50 // mm/s when a move has no usable feed
export function nextUpdateInterval(previousMs, gapMs) {
const previous =
previousMs > 0 ? previousMs : DEFAULT_UPDATE_INTERVAL_MS
if (!(gapMs >= MIN_SAMPLE_GAP_MS) || gapMs > MAX_SAMPLE_GAP_MS) {
return previous
}
const blended = previous * 0.6 + gapMs * 0.4
if (blended < MIN_UPDATE_INTERVAL_MS) return MIN_UPDATE_INTERVAL_MS
if (blended > MAX_UPDATE_INTERVAL_MS) return MAX_UPDATE_INTERVAL_MS
return blended
}
// Klipper reports virtual_sdcard.file_position in bytes. Our offsets are // 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 // 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. // a different size, in which case we fall back to proportional scaling.
@ -27,11 +58,13 @@ export function fileOffsetForPrinter(
// live toolhead position and return where along it the head is. // live toolhead position and return where along it the head is.
export function refineSegmentByPosition(store, segment, position) { export function refineSegmentByPosition(store, segment, position) {
const count = store.count const count = store.count
if (count === 0) return { segment: -1, t: 1 } if (count === 0) return { segment: -1, t: 1, snapped: false }
let seg = Math.min(Math.max(segment, 0), count - 1) let seg = Math.min(Math.max(segment, 0), count - 1)
if (!position || position.length < 3) return { segment: seg, t: 1 } if (!position || position.length < 3) return { segment: seg, t: 1, snapped: false }
const [px, py, pz] = position const [px, py, pz] = position
if (![px, py, pz].every(Number.isFinite)) return { segment: seg, t: 1 } if (![px, py, pz].every(Number.isFinite)) {
return { segment: seg, t: 1, snapped: false }
}
const positions = store.positions const positions = store.positions
const from = Math.max(0, seg - REFINE_WINDOW) const from = Math.max(0, seg - REFINE_WINDOW)
@ -68,9 +101,9 @@ export function refineSegmentByPosition(store, segment, position) {
} }
} }
if (bestSeg < 0 || bestD2 > MAX_SNAP_DISTANCE * MAX_SNAP_DISTANCE) { if (bestSeg < 0 || bestD2 > MAX_SNAP_DISTANCE * MAX_SNAP_DISTANCE) {
return { segment: seg, t: 1 } return { segment: seg, t: 1, snapped: false }
} }
return { segment: bestSeg, t: bestT } return { segment: bestSeg, t: bestT, snapped: true }
} }
export function pointOnSegment(store, segment, t, out = [0, 0, 0]) { export function pointOnSegment(store, segment, t, out = [0, 0, 0]) {
@ -100,3 +133,170 @@ export function layerIndexForSegment(layers, segment) {
} }
return result return result
} }
function finiteXYZ(position) {
if (!Array.isArray(position) || position.length < 3) return null
const x = position[0]
const y = position[1]
const z = position[2]
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null
}
return [x, y, z]
}
export function segmentLength(store, segment) {
const p = segment * 3
const positions = store.positions
const dx = positions[p + 3] - positions[p]
const dy = positions[p + 4] - positions[p + 1]
const dz = positions[p + 5] - positions[p + 2]
return Math.hypot(dx, dy, dz)
}
// Seconds to traverse `fraction` of a segment. Planned time already includes
// acceleration; feed (mm/s) is the fallback while the planner is still running.
export function segmentTravelSeconds(store, segment, fraction) {
if (!(fraction > 0)) return 0
if (store.plannerReady) {
const planned = store.segmentTime[segment]
if (planned > 0) return planned * fraction
}
const length = segmentLength(store, segment) * fraction
if (!(length > 0)) return 0
let speed = store.feed[segment]
if (!(speed > 0.2)) speed = FALLBACK_SPEED
return length / speed
}
// Where the printer is on the parsed path. The hot end follows this point
// rather than the raw sample, so motion stays on the toolpath.
export function resolveTrackSample(store, byteLength, gcodePosition, toolPosition) {
const live =
finiteXYZ(toolPosition) || finiteXYZ(gcodePosition?.position)
const offset = fileOffsetForPrinter(
gcodePosition?.filePosition,
gcodePosition?.fileSize,
byteLength
)
if (!store.count) {
if (!live) return null
return {
segment: -1,
t: 1,
x: live[0],
y: live[1],
z: live[2],
mapped: false
}
}
const fileSegment = store.segmentAtByteOffset(offset)
if (fileSegment < 0) {
if (!live) return null
return {
segment: -1,
t: 1,
x: live[0],
y: live[1],
z: live[2],
mapped: false
}
}
const refined = refineSegmentByPosition(store, fileSegment, live)
// A live position that isn't on the recent toolpath (jog, home, lookahead
// miss) stays at the reported coordinates. Following the file byte instead
// would pull the nozzle ahead of the printer.
if (live && !refined.snapped) {
return {
segment: refined.segment,
t: refined.t,
x: live[0],
y: live[1],
z: live[2],
mapped: false
}
}
const point = pointOnSegment(store, refined.segment, refined.t)
if (!point) {
if (!live) return null
return {
segment: -1,
t: 1,
x: live[0],
y: live[1],
z: live[2],
mapped: false
}
}
return {
segment: refined.segment,
t: refined.t,
x: point[0],
y: point[1],
z: point[2],
mapped: true
}
}
// Pieces of toolpath from (fromSeg, fromT) to (toSeg, toT), weighted by how
// long the firmware spends on each move. Null when the gap is a teleport or
// too large to be one update of motion.
export function buildPathSpan(store, fromSeg, fromT, toSeg, toT) {
if (fromSeg < 0 || toSeg < 0) return null
if (toSeg < fromSeg || toSeg - fromSeg > MAX_SPAN_SEGMENTS) return null
if (toSeg === fromSeg && toT < fromT - 1e-4) return null
const pieces = []
let total = 0
let length = 0
for (let i = fromSeg; i <= toSeg; i++) {
if (store.type[i] === FEATURE.NONE) return null
const t0 = i === fromSeg ? fromT : 0
const t1 = i === toSeg ? toT : 1
const fraction = t1 - t0
if (fraction < 1e-5) continue
const pieceLength = segmentLength(store, i) * fraction
if (!(pieceLength > 1e-6)) continue
const duration = segmentTravelSeconds(store, i, fraction)
if (!(duration > 0)) continue
total += duration
length += pieceLength
if (length > MAX_SPAN_LENGTH) return null
pieces.push({ segment: i, t0, t1, duration, end: total })
}
if (!pieces.length || !(total > 0)) return null
return { pieces, total, length }
}
export function samplePathSpan(span, u, store, out = [0, 0, 0]) {
const pieces = span.pieces
const last = pieces.length - 1
let target = span.total * u
if (target < 0) target = 0
else if (target > span.total) target = span.total
let lo = 0
let hi = last
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (pieces[mid].end < target) lo = mid + 1
else hi = mid
}
const piece = pieces[lo]
const start = piece.end - piece.duration
let local = 1
if (piece.duration > 1e-9) {
local = (target - start) / piece.duration
if (local < 0) local = 0
else if (local > 1) local = 1
}
const t = piece.t0 + (piece.t1 - piece.t0) * local
pointOnSegment(store, piece.segment, t, out)
return {
segment: piece.segment,
t,
x: out[0],
y: out[1],
z: out[2],
mapped: true
}
}

View File

@ -8,6 +8,10 @@ import { AuthContext } from '../context/AuthContext'
import GCodePreview from './GCodeViewer' import GCodePreview from './GCodeViewer'
import MissingPlaceholder from './MissingPlaceholder' import MissingPlaceholder from './MissingPlaceholder'
import ProgressDisplay from './ProgressDisplay' import ProgressDisplay from './ProgressDisplay'
import {
DEFAULT_UPDATE_INTERVAL_MS,
nextUpdateInterval
} from '../../Components/GCodePreview/GCodeViewer/tracking.js'
const emptyGcodePosition = { const emptyGcodePosition = {
position: [0, 0, 0, 0], position: [0, 0, 0, 0],
@ -19,6 +23,31 @@ const emptyGcodePosition = {
totalLayer: 0 totalLayer: 0
} }
// Motion and gcodePosition are emitted back to back. Keep them as one sample
// so the preview doesn't start a move on the file position and then correct
// it when the toolhead position arrives a moment later.
const SAMPLE_BURST_MS = 100
const SAMPLE_FLUSH_MS = 40
function createTrackState() {
return {
gcodePosition: emptyGcodePosition,
livePosition: null,
receivedAt: 0,
intervalMs: DEFAULT_UPDATE_INTERVAL_MS,
burstAt: 0
}
}
function toTrackSample(track) {
return {
gcodePosition: track.gcodePosition,
livePosition: track.livePosition,
receivedAt: track.receivedAt,
intervalMs: track.intervalMs
}
}
const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => { const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
const { subscribeToObjectEvent, connected, fetchObject, fetchFileContent } = const { subscribeToObjectEvent, connected, fetchObject, fetchFileContent } =
useContext(ApiServerContext) useContext(ApiServerContext)
@ -27,21 +56,53 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
apiRef.current = { fetchObject, fetchFileContent } apiRef.current = { fetchObject, fetchFileContent }
const objectUrlRef = useRef(null) const objectUrlRef = useRef(null)
const [gcodePosition, setGcodePosition] = useState(emptyGcodePosition) const trackRef = useRef(createTrackState())
// Physical toolhead position from motion_report.live_position; trails the const flushRef = useRef(0)
// commanded position and is what the hot end model follows. const noteTrackRef = useRef(null)
const [livePosition, setLivePosition] = useState(null) const [trackSample, setTrackSample] = useState(() =>
toTrackSample(trackRef.current)
)
const [src, setSrc] = useState(null) const [src, setSrc] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [downloadProgress, setDownloadProgress] = useState(null) const [downloadProgress, setDownloadProgress] = useState(null)
useEffect(() => { noteTrackRef.current = (patch) => {
if (offline) { const now = performance.now()
setGcodePosition(emptyGcodePosition) const track = trackRef.current
setLivePosition(null) const sinceBurst = track.burstAt > 0 ? now - track.burstAt : Infinity
if (sinceBurst >= SAMPLE_BURST_MS) {
if (track.receivedAt > 0) {
track.intervalMs = nextUpdateInterval(
track.intervalMs,
now - track.receivedAt
)
}
track.receivedAt = now
} }
}, [offline]) track.burstAt = now
if (patch.gcodePosition) {
track.gcodePosition = merge({}, track.gcodePosition, patch.gcodePosition)
}
if (patch.livePosition !== undefined) {
track.livePosition = patch.livePosition
}
if (flushRef.current) clearTimeout(flushRef.current)
flushRef.current = window.setTimeout(() => {
flushRef.current = 0
setTrackSample(toTrackSample(trackRef.current))
}, SAMPLE_FLUSH_MS)
}
useEffect(() => {
if (flushRef.current) {
clearTimeout(flushRef.current)
flushRef.current = 0
}
const fresh = createTrackState()
trackRef.current = fresh
setTrackSample(toTrackSample(fresh))
}, [id, offline])
useEffect(() => { useEffect(() => {
if (id && connected == true && offline == false) { if (id && connected == true && offline == false) {
@ -51,7 +112,7 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
'printer', 'printer',
'gcodePosition', 'gcodePosition',
(event) => { (event) => {
setGcodePosition((prev) => merge({}, prev, event.data)) noteTrackRef.current?.({ gcodePosition: event.data })
} }
) )
const motionUnsubscribe = subscribeToObjectEvent( const motionUnsubscribe = subscribeToObjectEvent(
@ -60,19 +121,26 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
'motion', 'motion',
(event) => { (event) => {
const live = event.data?.livePosition const live = event.data?.livePosition
if (Array.isArray(live) && live.length >= 3) { if (!Array.isArray(live) || live.length < 3) return
setLivePosition((prev) => const prev = trackRef.current.livePosition
prev && if (
prev[0] === live[0] && prev &&
prev[1] === live[1] && prev[0] === live[0] &&
prev[2] === live[2] prev[1] === live[1] &&
? prev prev[2] === live[2]
: [live[0], live[1], live[2]] ) {
) return
} }
noteTrackRef.current?.({
livePosition: [live[0], live[1], live[2]]
})
} }
) )
return () => { return () => {
if (flushRef.current) {
clearTimeout(flushRef.current)
flushRef.current = 0
}
if (gcodePositionUnsubscribe) gcodePositionUnsubscribe() if (gcodePositionUnsubscribe) gcodePositionUnsubscribe()
if (motionUnsubscribe) motionUnsubscribe() if (motionUnsubscribe) motionUnsubscribe()
} }
@ -208,8 +276,10 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
<GCodePreview <GCodePreview
src={src} src={src}
showFollowCurrentSubJob={true} showFollowCurrentSubJob={true}
gcodePosition={gcodePosition} gcodePosition={trackSample.gcodePosition}
toolPosition={livePosition} toolPosition={trackSample.livePosition}
sampleTime={trackSample.receivedAt}
updateIntervalMs={trackSample.intervalMs}
style={{ width: '100%', height: '60vh' }} style={{ width: '100%', height: '60vh' }}
/> />
)} )}