diff --git a/src/components/Components/GCodePreview/GCodePreview.jsx b/src/components/Components/GCodePreview/GCodePreview.jsx index 75e409c8..aa76351a 100644 --- a/src/components/Components/GCodePreview/GCodePreview.jsx +++ b/src/components/Components/GCodePreview/GCodePreview.jsx @@ -27,10 +27,12 @@ import { } from './GCodeViewer/colorModes.js' import { ALL_TYPES_MASK, FEATURE } from './GCodeViewer/featureTypes.js' import { - fileOffsetForPrinter, + buildPathSpan, + DEFAULT_UPDATE_INTERVAL_MS, layerIndexForSegment, pointOnSegment, - refineSegmentByPosition + resolveTrackSample, + samplePathSpan } from './GCodeViewer/tracking.js' const { Text } = Typography @@ -82,16 +84,6 @@ function applyCameraScale(scene, baseDistance, scale) { 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, @@ -100,7 +92,12 @@ function GCodePreview(props) { style = {}, showFollowCurrentSubJob = false, 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 const { isDarkMode } = useThemeContext() @@ -313,36 +310,239 @@ function GCodePreview(props) { // ---- 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(() => { - 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 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) { + if (scrubSegment != null && ready) { const end = Math.max(0, Math.min(store.count, Math.round(scrubSegment))) segments.setProgress(end, 1) if (end > 0) { @@ -357,16 +557,7 @@ function GCodePreview(props) { scene.setHotEndVisible(false) } scene.requestRender() - }, [ - scene, - model, - revision, - ready, - tracking, - gcodePosition, - toolPosition, - scrubSegment - ]) + }, [scene, model, revision, ready, tracking, scrubSegment]) const scrubRef = useRef(scrubSegment) scrubRef.current = scrubSegment @@ -483,20 +674,14 @@ function GCodePreview(props) { const statsMoveIndex = useMemo(() => { if (!ready || !model) return 0 if (tracking && gcodePosition) { - const offset = fileOffsetForPrinter( - gcodePosition.filePosition, - gcodePosition.fileSize, - model.byteLength || model.size + const sample = resolveTrackSample( + model.store, + model.byteLength || model.size, + gcodePosition, + toolPosition ) - 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 + if (!sample || sample.segment < 0) return 0 + return sample.segment + 1 } return scrubValue // revision: segment store grows while chunks stream in during tracking. @@ -819,7 +1004,9 @@ GCodePreview.propTypes = { currentLayer: PropTypes.number, totalLayer: PropTypes.number }), - toolPosition: PropTypes.arrayOf(PropTypes.number) + toolPosition: PropTypes.arrayOf(PropTypes.number), + sampleTime: PropTypes.number, + updateIntervalMs: PropTypes.number } export default GCodePreview diff --git a/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js b/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js index 94e00497..18225de6 100644 --- a/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js +++ b/src/components/Components/GCodePreview/GCodeViewer/parser.worker.js @@ -1094,6 +1094,7 @@ class GCodeParser { count: store.count, end: [this.px, this.py, this.pz], actualSpeed, + segmentTime, layerTime, layers, metadata: this.metadata, @@ -1107,7 +1108,7 @@ class GCodeParser { hasTypeComments: this.sawTypeComment, planner: plannerOptions }, - [actualSpeed.buffer, layerTime.buffer] + [actualSpeed.buffer, segmentTime.buffer, layerTime.buffer] ) } diff --git a/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js b/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js index 4f910777..d99f3495 100644 --- a/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js +++ b/src/components/Components/GCodePreview/GCodeViewer/segmentStore.js @@ -21,6 +21,7 @@ export const SEGMENT_FIELDS = [ // Filled after the planner pass, not part of streamed chunks. export const PLANNER_FIELDS = [ ['actualSpeed', Float32Array], + ['segmentTime', Float32Array], ['layerTime', Float32Array] ] @@ -140,8 +141,9 @@ export default class SegmentStore { this.count = start + count } - applyPlanner({ actualSpeed, layerTime }) { + applyPlanner({ actualSpeed, segmentTime, layerTime }) { 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)) this.plannerReady = true } diff --git a/src/components/Components/GCodePreview/GCodeViewer/tracking.js b/src/components/Components/GCodePreview/GCodeViewer/tracking.js index 5d2f77c3..d4d4ccb6 100644 --- a/src/components/Components/GCodePreview/GCodeViewer/tracking.js +++ b/src/components/Components/GCodePreview/GCodeViewer/tracking.js @@ -1,8 +1,39 @@ // 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 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 // 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. @@ -27,11 +58,13 @@ export function fileOffsetForPrinter( // 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 } + if (count === 0) return { segment: -1, t: 1, snapped: false } 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 - 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 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) { - 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]) { @@ -100,3 +133,170 @@ export function layerIndexForSegment(layers, segment) { } 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 + } +} diff --git a/src/components/Dashboard/common/PrinterGCodeFilePanel.jsx b/src/components/Dashboard/common/PrinterGCodeFilePanel.jsx index b1566b95..1295e851 100644 --- a/src/components/Dashboard/common/PrinterGCodeFilePanel.jsx +++ b/src/components/Dashboard/common/PrinterGCodeFilePanel.jsx @@ -8,6 +8,10 @@ import { AuthContext } from '../context/AuthContext' import GCodePreview from './GCodeViewer' import MissingPlaceholder from './MissingPlaceholder' import ProgressDisplay from './ProgressDisplay' +import { + DEFAULT_UPDATE_INTERVAL_MS, + nextUpdateInterval +} from '../../Components/GCodePreview/GCodeViewer/tracking.js' const emptyGcodePosition = { position: [0, 0, 0, 0], @@ -19,6 +23,31 @@ const emptyGcodePosition = { 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 { subscribeToObjectEvent, connected, fetchObject, fetchFileContent } = useContext(ApiServerContext) @@ -27,21 +56,53 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => { apiRef.current = { fetchObject, fetchFileContent } const objectUrlRef = useRef(null) - const [gcodePosition, setGcodePosition] = useState(emptyGcodePosition) - // Physical toolhead position from motion_report.live_position; trails the - // commanded position and is what the hot end model follows. - const [livePosition, setLivePosition] = useState(null) + const trackRef = useRef(createTrackState()) + const flushRef = useRef(0) + const noteTrackRef = useRef(null) + const [trackSample, setTrackSample] = useState(() => + toTrackSample(trackRef.current) + ) 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) - setLivePosition(null) + noteTrackRef.current = (patch) => { + const now = performance.now() + const track = trackRef.current + 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(() => { if (id && connected == true && offline == false) { @@ -51,7 +112,7 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => { 'printer', 'gcodePosition', (event) => { - setGcodePosition((prev) => merge({}, prev, event.data)) + noteTrackRef.current?.({ gcodePosition: event.data }) } ) const motionUnsubscribe = subscribeToObjectEvent( @@ -60,19 +121,26 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => { 'motion', (event) => { const live = event.data?.livePosition - if (Array.isArray(live) && live.length >= 3) { - setLivePosition((prev) => - prev && - prev[0] === live[0] && - prev[1] === live[1] && - prev[2] === live[2] - ? prev - : [live[0], live[1], live[2]] - ) + if (!Array.isArray(live) || live.length < 3) return + const prev = trackRef.current.livePosition + if ( + prev && + prev[0] === live[0] && + prev[1] === live[1] && + prev[2] === live[2] + ) { + return } + noteTrackRef.current?.({ + livePosition: [live[0], live[1], live[2]] + }) } ) return () => { + if (flushRef.current) { + clearTimeout(flushRef.current) + flushRef.current = 0 + } if (gcodePositionUnsubscribe) gcodePositionUnsubscribe() if (motionUnsubscribe) motionUnsubscribe() } @@ -208,8 +276,10 @@ const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => { )}