Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 191 additions & 44 deletions apps/web/src/lib/gcodeVisualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const motionColor = {
'G3': new Color(colornames('deepskyblue') as string)
}

// Minimum squared distance between consecutive vertices (mm²).
// Vertices closer than this are skipped during visualization to avoid
// creating huge geometry for files with tiny XY movements (e.g. continuous
// A-axis rotation programs where XYZ barely changes between lines).
// 0.1mm is sub-pixel at typical CNC visualization zoom levels.
const MIN_DISTANCE_SQ = 0.1 * 0.1

export interface GCodeFrame {
data: string
vertexIndex: number
Expand All @@ -31,43 +38,53 @@ let _cachedGcodeInput: string | null = null
let _cachedGcodeResult: GCodeGeometryResult | null = null

/**
* Process G-code string and generate Three.js BufferGeometry for visualization.
* Results are cached - repeated calls with the same gcode string return the cached result.
* Shared parsing logic used by both sync and async code paths.
* Creates a Toolpath instance with addLine/addArcCurve callbacks that apply
* vertex decimation - skipping vertices within MIN_DISTANCE_SQ of the previous one.
*
* @param gcode - G-code string to process
* @returns Geometry data with frames for animation/stepping through the toolpath
* Returns the toolpath interpreter and the arrays it populates.
*/
export function processGCode(gcode: string | null | undefined): GCodeGeometryResult | null {
if (!gcode) {
return null
}

// Return cached result if input hasn't changed
if (gcode === _cachedGcodeInput && _cachedGcodeResult) {
return _cachedGcodeResult
}

function createDecimatedToolpath() {
const positions: number[] = []
const colors: number[] = []
const frames: GCodeFrame[] = []
let initialPosition: Vector3 | undefined = undefined // Track the toolpath origin (v1 from first addLine call)
let initialPosition: Vector3 | undefined = undefined

// Decimation state - tracks the last emitted vertex for distance checks
let lastX = NaN
let lastY = NaN
let lastZ = NaN
let lastMotion: string | undefined = undefined

// Create toolpath processor with callbacks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toolpath: any = new (Toolpath as any)({
// Called for each line segment (G0, G1 moves)
// Note: The toolpath library ensures continuity - v1 of current line = v2 of previous line
// So we only push v2 (the endpoint), not v1 - matching legacy implementation
addLine: (modal: { motion?: string }, v1: { x: number; y: number; z: number }, v2: { x: number; y: number; z: number }) => {
const { motion } = modal
const color = motion ? (motionColor[motion as keyof typeof motionColor] || defaultColor) : defaultColor

// Capture the initial position from the first move's start point (v1)
if (initialPosition === undefined) {
initialPosition = new Vector3(v1.x, v1.y, v1.z)
}

// Only push the endpoint - matching legacy code exactly

// Vertex decimation: skip points too close to the previous vertex.
// Always keep vertex when motion type changes (G0↔G1 transitions)
// to preserve color boundaries in the polyline.
const dx = v2.x - lastX
const dy = v2.y - lastY
const dz = v2.z - lastZ
const distSq = dx * dx + dy * dy + dz * dz

if (distSq < MIN_DISTANCE_SQ && motion === lastMotion) {
return
}

lastX = v2.x
lastY = v2.y
lastZ = v2.z
lastMotion = motion

positions.push(v2.x, v2.y, v2.z)
colors.push(color.r, color.g, color.b)
},
Expand All @@ -80,7 +97,7 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes
v0: { x: number; y: number; z: number }
) => {
const { motion, plane } = modal

// Capture the initial position from the first arc's start point (v1) if not already set
if (initialPosition === undefined) {
initialPosition = new Vector3(v1.x, v1.y, v1.z)
Expand All @@ -97,8 +114,6 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes
endAngle += (2 * Math.PI)
}

// Use THREE.ArcCurve to properly handle clockwise/counterclockwise arcs
// This matches the legacy implementation exactly
const arcCurve = new ArcCurve(
v0.x, // aX
v0.y, // aY
Expand All @@ -115,47 +130,65 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes
const point = points[i]
const z = v1.z + ((v2.z - v1.z) / points.length) * i

let px: number, py: number, pz: number
if (plane === 'G17') { // XY-plane
positions.push(point.x, point.y, z)
px = point.x; py = point.y; pz = z
} else if (plane === 'G18') { // ZX-plane
positions.push(point.y, z, point.x)
px = point.y; py = z; pz = point.x
} else if (plane === 'G19') { // YZ-plane
positions.push(z, point.x, point.y)
px = z; py = point.x; pz = point.y
} else {
// Default to XY-plane if plane is not specified
positions.push(point.x, point.y, z)
px = point.x; py = point.y; pz = z
}

// Apply decimation to arc points too, but always keep the last
// point of each arc for continuity with the next segment
const isLast = (i === points.length - 1)
if (!isLast) {
const adx = px - lastX
const ady = py - lastY
const adz = pz - lastZ
if (adx * adx + ady * ady + adz * adz < MIN_DISTANCE_SQ) {
continue
}
}

lastX = px
lastY = py
lastZ = pz

positions.push(px, py, pz)
colors.push(color.r, color.g, color.b)
}

lastMotion = motion
}
})

// Process G-code synchronously
// Call method directly on toolpath instance to preserve 'this' context
if (toolpath && typeof (toolpath as { loadFromStringSync?: (gcode: string, callback: (line: string) => void) => void }).loadFromStringSync === 'function') {
(toolpath as { loadFromStringSync: (gcode: string, callback: (line: string) => void) => void }).loadFromStringSync(gcode, (line: string) => {
frames.push({
data: line,
vertexIndex: Math.floor(positions.length / 3) // Current vertex count
})
})
}
return { toolpath, positions, colors, frames, getInitialPosition: () => initialPosition }
}

// Create BufferGeometry
/**
* Build a GCodeGeometryResult from raw position/color/frame arrays.
*/
function buildGeometryResult(
positions: number[],
colors: number[],
frames: GCodeFrame[],
initialPosition: Vector3 | undefined
): GCodeGeometryResult {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
geometry.setAttribute('color', new BufferAttribute(new Float32Array(colors), 3))
geometry.computeBoundingBox()

// Calculate bounding box
let boundingBox: { min: Vector3; max: Vector3 } | undefined
if (geometry.boundingBox) {
boundingBox = {
min: geometry.boundingBox.min.clone(),
max: geometry.boundingBox.max.clone()
}
} else if (positions.length > 0) {
// Manual bounding box calculation if computeBoundingBox didn't set it
let minX = Infinity, minY = Infinity, minZ = Infinity
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
for (let i = 0; i < positions.length; i += 3) {
Expand All @@ -172,13 +205,127 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes
}
}

const result: GCodeGeometryResult = {
return {
geometry,
frames,
boundingBox,
firstVertex: initialPosition // Return the toolpath origin (initial position)
firstVertex: initialPosition
}
}

/**
* Process G-code string synchronously and generate Three.js BufferGeometry.
* Results are cached - repeated calls with the same gcode string return the cached result.
* Applies vertex decimation to skip near-identical vertices.
*
* @param gcode - G-code string to process
* @returns Geometry data with frames for animation/stepping through the toolpath
*/
export function processGCode(gcode: string | null | undefined): GCodeGeometryResult | null {
if (!gcode) {
return null
}

// Return cached result if input hasn't changed
if (gcode === _cachedGcodeInput && _cachedGcodeResult) {
return _cachedGcodeResult
}

const { toolpath, positions, colors, frames, getInitialPosition } = createDecimatedToolpath()

// Process G-code synchronously
if (toolpath && typeof (toolpath as { loadFromStringSync?: (gcode: string, callback: (line: string) => void) => void }).loadFromStringSync === 'function') {
(toolpath as { loadFromStringSync: (gcode: string, callback: (line: string) => void) => void }).loadFromStringSync(gcode, (line: string) => {
frames.push({
data: line,
vertexIndex: Math.floor(positions.length / 3)
})
})
}

const result = buildGeometryResult(positions, colors, frames, getInitialPosition())

// Cache the result
_cachedGcodeInput = gcode
_cachedGcodeResult = result

return result
}

// Number of G-code lines to process per chunk before yielding to the UI thread.
// Larger chunks = less overhead but longer freezes; smaller = smoother UI but slower overall.
const ASYNC_CHUNK_SIZE = 5000

/**
* Process G-code asynchronously in chunks, yielding to the UI thread between
* chunks so the loading spinner stays animated and the browser remains responsive.
*
* Populates the same cache as processGCode, so subsequent sync calls return
* the cached result instantly.
*
* @param gcode - G-code string to process
* @param signal - Optional AbortSignal to cancel processing early
* @returns Geometry data, or null if cancelled / empty input
*/
export async function processGCodeAsync(
gcode: string | null | undefined,
signal?: AbortSignal
): Promise<GCodeGeometryResult | null> {
if (!gcode) {
return null
}

// Return cached result if input hasn't changed
if (gcode === _cachedGcodeInput && _cachedGcodeResult) {
return _cachedGcodeResult
}

const { toolpath, positions, colors, frames, getInitialPosition } = createDecimatedToolpath()

if (!toolpath || typeof (toolpath as { loadFromStringSync?: unknown }).loadFromStringSync !== 'function') {
return null
}

const typedToolpath = toolpath as { loadFromStringSync: (gcode: string, callback: (line: string) => void) => void }

// Split into lines and process in chunks
const lines = gcode.split('\n')
const totalLines = lines.length

for (let start = 0; start < totalLines; start += ASYNC_CHUNK_SIZE) {
// Check for cancellation between chunks
if (signal?.aborted) {
return null
}

// If the sync path populated the cache while we were yielding, use it
if (gcode === _cachedGcodeInput && _cachedGcodeResult) {
return _cachedGcodeResult
}

const end = Math.min(start + ASYNC_CHUNK_SIZE, totalLines)
const chunk = lines.slice(start, end).join('\n')

typedToolpath.loadFromStringSync(chunk, (line: string) => {
frames.push({
data: line,
vertexIndex: Math.floor(positions.length / 3)
})
})

// Yield to UI thread between chunks (skip yield for the last chunk)
if (end < totalLines) {
await new Promise<void>(resolve => setTimeout(resolve, 0))
}
}

// Final cancellation check
if (signal?.aborted) {
return null
}

const result = buildGeometryResult(positions, colors, frames, getInitialPosition())

// Cache the result
_cachedGcodeInput = gcode
_cachedGcodeResult = result
Expand Down
47 changes: 22 additions & 25 deletions apps/web/src/routes/Setup/components/VisualizerScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Loader2 } from 'lucide-react'
import { useGetSettingsQuery, useGetExtensionsQuery } from '@/services/api'
import { machineToThree, type MachineLimits, type Coordinate } from '@/lib/coordinates'
import type { HomingCorner } from '@/lib/machineLimits'
import { processGCode } from '@/lib/gcodeVisualizer'
import { processGCode, processGCodeAsync } from '@/lib/gcodeVisualizer'

export type VizMode = 'machine' | 'wcs'

Expand Down Expand Up @@ -622,9 +622,9 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine
const { t } = useTranslation()
const [webglAvailable, setWebglAvailable] = useState<boolean | null>(null)

// Deferred gcode loading: show loading indicator in outer DOM, then pass gcode to Canvas
// after the browser has painted the overlay. This ensures the spinner is visible before
// the heavy processGCode call blocks the main thread inside the Canvas.
// Async gcode loading: process gcode in background chunks so the UI stays
// responsive, then pass the gcode string to GCodeToolpath once the cache is
// populated (so its synchronous useMemo hits the cache instantly).
const [deferredGcode, setDeferredGcode] = useState<string | null | undefined>(gcode)
const [isProcessing, setIsProcessing] = useState(false)

Expand All @@ -635,34 +635,31 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine
return
}

// Show loading overlay immediately
// Show loading overlay and clear stale geometry while processing
setIsProcessing(true)
setDeferredGcode(null) // Clear stale geometry while loading

// Double requestAnimationFrame ensures the browser has actually painted
// the loading overlay before we let the heavy processGCode run
let rafId1 = 0
let rafId2 = 0
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// This triggers GCodeToolpath's useMemo which runs processGCode synchronously.
// The loading spinner will be visible (though frozen) during the blocking parse.
setDeferredGcode(null)

const abortController = new AbortController()

// processGCodeAsync processes gcode in chunks, yielding to the UI thread
// between chunks so the loading spinner stays animated. When it resolves
// the result is cached, so GCodeToolpath's sync processGCode call is instant.
processGCodeAsync(gcode, abortController.signal).then(() => {
if (!abortController.signal.aborted) {
setDeferredGcode(gcode)
})
setIsProcessing(false)
}
}).catch(() => {
if (!abortController.signal.aborted) {
setIsProcessing(false)
}
})

return () => {
cancelAnimationFrame(rafId1)
cancelAnimationFrame(rafId2)
abortController.abort()
}
}, [gcode])

// Clear loading state after deferred gcode has been processed and rendered
useEffect(() => {
if (deferredGcode && isProcessing) {
setIsProcessing(false)
}
}, [deferredGcode]) // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
if (typeof window === 'undefined') {
setWebglAvailable(false)
Expand Down
Loading