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'
|
} 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
|
||||||
|
|||||||
@ -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]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user