Enhance GCodePreview and Tracking Logic for Improved Motion Handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
4a90675ef5
commit
0e9b6392c2
@ -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
|
||||
|
||||
@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }) => {
|
||||
<GCodePreview
|
||||
src={src}
|
||||
showFollowCurrentSubJob={true}
|
||||
gcodePosition={gcodePosition}
|
||||
toolPosition={livePosition}
|
||||
gcodePosition={trackSample.gcodePosition}
|
||||
toolPosition={trackSample.livePosition}
|
||||
sampleTime={trackSample.receivedAt}
|
||||
updateIntervalMs={trackSample.intervalMs}
|
||||
style={{ width: '100%', height: '60vh' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user