From 444cd1de3fd1c136c076657354b8caf009d44c75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 20:46:11 +0000 Subject: [PATCH] Fix NC file loading freeze with vertex decimation and async chunked processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large G-code files with continuous A-axis rotation caused the UI to freeze because every line created a vertex even when X/Y/Z barely changed between lines, and all parsing happened synchronously on the main thread. Two fixes applied: 1. Vertex decimation: Skip vertices within 0.1mm of the previous one (sub-pixel at any zoom level). This dramatically reduces geometry size for A-axis rotation programs where thousands of lines produce nearly identical XYZ coordinates. Motion type transitions (G0↔G1) always keep both vertices to preserve color boundaries. 2. Async chunked processing: processGCodeAsync splits the G-code into 5000-line chunks with setTimeout(0) yields between them, keeping the loading spinner animated and the browser responsive. The result populates the same cache as the sync path, so GCodeToolpath's useMemo hits the cache instantly. https://claude.ai/code/session_01XToJJomq1oMrbTxNLuJDjV --- apps/web/src/lib/gcodeVisualizer.ts | 235 ++++++++++++++---- .../Setup/components/VisualizerScene.tsx | 47 ++-- 2 files changed, 213 insertions(+), 69 deletions(-) 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)