diff --git a/apps/web/src/lib/gcodeVisualizer.ts b/apps/web/src/lib/gcodeVisualizer.ts index d481005a7..025b7ef01 100644 --- a/apps/web/src/lib/gcodeVisualizer.ts +++ b/apps/web/src/lib/gcodeVisualizer.ts @@ -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 @@ -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) }, @@ -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) @@ -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 @@ -115,39 +130,58 @@ 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 = { @@ -155,7 +189,6 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes 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) { @@ -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 { + 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(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 diff --git a/apps/web/src/routes/Setup/components/VisualizerScene.tsx b/apps/web/src/routes/Setup/components/VisualizerScene.tsx index aac0b51e8..a11d24595 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -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' @@ -622,9 +622,9 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine const { t } = useTranslation() const [webglAvailable, setWebglAvailable] = useState(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(gcode) const [isProcessing, setIsProcessing] = useState(false) @@ -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)