From 5c48d053428c5866950ac3edfeaca22e5c696964 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:46:42 +0000 Subject: [PATCH 1/4] Optimize viewer performance for complex toolpaths - Cache processGCode results to avoid redundant parsing (was called 3-4x for the same G-code: visualizer, outline calc, auto-place, place-model) - Fix outline recalculation firing on every machinePosition update - it was re-parsing the entire G-code and computing concave hull on every toolhead move despite machinePosition not being needed for hull calc - Make color updates incremental: only paint newly processed lines red instead of resetting ALL vertex colors and repainting from scratch - Switch Canvas to frameloop="demand" so the scene only re-renders when something actually changes instead of running at 60fps continuously https://claude.ai/code/session_01Y2PzWVYwZn4BTd37TWvEVB --- apps/web/src/lib/gcodeVisualizer.ts | 22 ++++- .../Setup/components/VisualizerScene.tsx | 96 +++++++++++-------- .../routes/Setup/panels/VisualizerPanel.tsx | 9 +- 3 files changed, 82 insertions(+), 45 deletions(-) diff --git a/apps/web/src/lib/gcodeVisualizer.ts b/apps/web/src/lib/gcodeVisualizer.ts index 9f90bed0b..d481005a7 100644 --- a/apps/web/src/lib/gcodeVisualizer.ts +++ b/apps/web/src/lib/gcodeVisualizer.ts @@ -26,9 +26,14 @@ export interface GCodeGeometryResult { firstVertex?: Vector3 // First vertex position for offset calculations } +// Simple cache for processGCode to avoid redundant parsing of the same G-code +let _cachedGcodeInput: string | null = null +let _cachedGcodeResult: GCodeGeometryResult | null = null + /** - * Process G-code string and generate Three.js BufferGeometry for visualization - * + * 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. + * * @param gcode - G-code string to process * @returns Geometry data with frames for animation/stepping through the toolpath */ @@ -37,6 +42,11 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes return null } + // Return cached result if input hasn't changed + if (gcode === _cachedGcodeInput && _cachedGcodeResult) { + return _cachedGcodeResult + } + const positions: number[] = [] const colors: number[] = [] const frames: GCodeFrame[] = [] @@ -162,10 +172,16 @@ export function processGCode(gcode: string | null | undefined): GCodeGeometryRes } } - return { + const result: GCodeGeometryResult = { geometry, frames, boundingBox, firstVertex: initialPosition // Return the toolpath origin (initial position) } + + // Cache the result + _cachedGcodeInput = gcode + _cachedGcodeResult = result + + return result } diff --git a/apps/web/src/routes/Setup/components/VisualizerScene.tsx b/apps/web/src/routes/Setup/components/VisualizerScene.tsx index 427e59b7d..049ca69fa 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -220,7 +220,9 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | const geometryRef = useRef(null) const framesRef = useRef>([]) const originalColorsRef = useRef(null) + const prevProcessedLinesRef = useRef(0) // Track previous count for incremental updates const redColor = useMemo(() => new Color(1, 0, 0), []) // Red color for processed lines + const invalidate = useThree((state) => state.invalidate) const geometry = useMemo(() => { const result = processGCode(gcode) @@ -228,6 +230,7 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | geometryRef.current = null framesRef.current = [] originalColorsRef.current = null + prevProcessedLinesRef.current = 0 return null } @@ -235,6 +238,7 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | framesRef.current = result.frames const colorAttr = result.geometry.getAttribute('color') as BufferAttribute originalColorsRef.current = colorAttr ? (colorAttr.array as Float32Array).slice() : null + prevProcessedLinesRef.current = 0 // Reset on new geometry // Apply offset if provided if (offset && (offset.x !== 0 || offset.y !== 0 || offset.z !== 0)) { @@ -271,7 +275,7 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | offset, ]) - // Update colors based on processed lines + // Update colors based on processed lines - incremental updates only paint the delta useEffect(() => { if (!geometryRef.current || !originalColorsRef.current || framesRef.current.length === 0) { return @@ -287,29 +291,48 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | } const colors = colorAttr.array as Float32Array + const current = Math.min(processedLines ?? 0, frames.length) + const prev = prevProcessedLinesRef.current - // Reset all colors to original - colors.set(originalColors) - - // Turn processed lines red - const linesToPaint = Math.min(processedLines ?? 0, frames.length) - for (let i = 0; i < linesToPaint; i++) { - const frame = frames[i] - const startVertexIndex = frame.vertexIndex - // Find the end vertex index (next frame's vertexIndex, or end of geometry) - const endVertexIndex = i < frames.length - 1 ? frames[i + 1].vertexIndex : colors.length / 3 - - // Update colors for all vertices in this line segment - for (let v = startVertexIndex; v < endVertexIndex; v++) { - const colorIndex = v * 3 - colors[colorIndex] = redColor.r - colors[colorIndex + 1] = redColor.g - colors[colorIndex + 2] = redColor.b + if (current === prev) { + return // No change + } + + if (current > prev) { + // Incremental: only paint newly processed lines red + for (let i = prev; i < current; i++) { + const frame = frames[i] + const startVertexIndex = frame.vertexIndex + const endVertexIndex = i < frames.length - 1 ? frames[i + 1].vertexIndex : colors.length / 3 + + for (let v = startVertexIndex; v < endVertexIndex; v++) { + const colorIndex = v * 3 + colors[colorIndex] = redColor.r + colors[colorIndex + 1] = redColor.g + colors[colorIndex + 2] = redColor.b + } + } + } else { + // Went backwards (e.g. reset) - restore original colors then repaint + colors.set(originalColors) + for (let i = 0; i < current; i++) { + const frame = frames[i] + const startVertexIndex = frame.vertexIndex + const endVertexIndex = i < frames.length - 1 ? frames[i + 1].vertexIndex : colors.length / 3 + + for (let v = startVertexIndex; v < endVertexIndex; v++) { + const colorIndex = v * 3 + colors[colorIndex] = redColor.r + colors[colorIndex + 1] = redColor.g + colors[colorIndex + 2] = redColor.b + } } } + prevProcessedLinesRef.current = current colorAttr.needsUpdate = true - }, [processedLines, redColor]) + invalidate() // Request re-render for demand mode + }, [processedLines, redColor, invalidate]) // Create line object - must be before early return to satisfy Rules of Hooks const lineObject = useMemo(() => { @@ -407,23 +430,18 @@ function ToolIndicator({ position = [0, 0, 50] }: { position?: [number, number, function BillboardText({ position, children, fontSize = 20, ...props }: React.ComponentProps) { const groupRef = useRef(null) const { camera } = useThree() - + useFrame(() => { if (groupRef.current) { // Face camera groupRef.current.quaternion.copy(camera.quaternion) - + // Calculate distance from camera to text position const distance = camera.position.distanceTo(groupRef.current.position) - - // Scale proportionally with distance to maintain constant screen size - // As camera moves farther, scale increases to keep text same size on screen - // Base distance reference: use a reference distance (e.g., 100 units) - // Scale = currentDistance / referenceDistance + const referenceDistance = 100 const scale = distance / referenceDistance - - // Scale the entire group (which contains the Text) + groupRef.current.scale.setScalar(scale) } }) @@ -493,7 +511,7 @@ function WCSGrid({ size = 200, divisions = 20 }: { size?: number; divisions?: nu // Camera controller component that responds to view changes function CameraController({ xSize, ySize, zSize, view, viewKey }: { xSize: number; ySize: number; zSize: number; view?: 'top' | 'front' | 'iso' | 'fit'; viewKey?: number }) { - const { camera } = useThree() + const { camera, invalidate } = useThree() const controlsRef = useRef>(null) const gridCenterX = xSize / 2 @@ -585,16 +603,18 @@ function CameraController({ xSize, ySize, zSize, view, viewKey }: { xSize: numbe } controls.update() } - }, [view, viewKey, camera, gridCenterX, gridCenterY, gridCenterZ, maxGridSize, xSize, ySize, zSize]) - + invalidate() // Request re-render after camera change in demand mode + }, [view, viewKey, camera, invalidate, gridCenterX, gridCenterY, gridCenterZ, maxGridSize, xSize, ySize, zSize]) + return ( - invalidate()} // Re-render on orbit interaction // Ensure Z is always up for natural CNC machine orientation // This makes orbiting feel more natural with the front of the work envelope as the natural viewing direction /> @@ -690,7 +710,7 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine return (
- + {/* Camera setup - positioned to see the full grid */} { - if (loadedGcode?.gcode && machinePosition) { - const outlineResult = calculateOutline(loadedGcode.gcode, machinePosition, { + if (loadedGcode?.gcode) { + const currentMachinePosition = machinePositionRef.current + const outlineResult = calculateOutline(loadedGcode.gcode, currentMachinePosition, { concavity: 2, minPointDistance: 1, // 1mm minimum distance between points }) @@ -473,7 +474,7 @@ export function VisualizerPanel({ } else { setOutlinePoints(null) } - }, [loadedGcode?.gcode, machinePosition]) + }, [loadedGcode?.gcode]) // eslint-disable-line react-hooks/exhaustive-deps // Automatically place model at WCS origin when G-code is loaded useEffect(() => { From 530b535900d9b49a9959fa6e5eb400d92dd4958f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:55:15 +0000 Subject: [PATCH 2/4] Add loading indicator for toolpath processing Show a spinner overlay with "Processing toolpath..." when loading complex G-code files. Moves processGCode from synchronous useMemo to a deferred useEffect (setTimeout(0)) so React can paint the loading state before the heavy parsing work blocks the main thread. https://claude.ai/code/session_01Y2PzWVYwZn4BTd37TWvEVB --- .../Setup/components/VisualizerScene.tsx | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/apps/web/src/routes/Setup/components/VisualizerScene.tsx b/apps/web/src/routes/Setup/components/VisualizerScene.tsx index 049ca69fa..569c84cf9 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef, useState } from 'react' +import { useMemo, useEffect, useRef, useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' import type { ComponentRef } from 'react' import { Color } from 'three' @@ -6,6 +6,7 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber' import { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei' import { BufferGeometry, BufferAttribute, ArrowHelper, Vector3, LineBasicMaterial, Line, LineDashedMaterial, PlaneGeometry, EdgesGeometry, Group } from 'three' import type { Vector3 as Vector3Type } from 'three' +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' @@ -216,7 +217,7 @@ function ZTopRectangle({ length, gridSizeX, gridSizeY }: { length: number; gridS } // G-code toolpath visualization component -function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | null; offset?: Vector3Type; processedLines?: number }) { +function GCodeToolpath({ gcode, offset, processedLines = 0, onLoadingChange }: { gcode?: string | null; offset?: Vector3Type; processedLines?: number; onLoadingChange?: (loading: boolean) => void }) { const geometryRef = useRef(null) const framesRef = useRef>([]) const originalColorsRef = useRef(null) @@ -224,56 +225,74 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | const redColor = useMemo(() => new Color(1, 0, 0), []) // Red color for processed lines const invalidate = useThree((state) => state.invalidate) - const geometry = useMemo(() => { - const result = processGCode(gcode) - if (!result?.geometry) { + // Process G-code asynchronously so loading indicator can paint before heavy work + const [geometry, setGeometry] = useState(null) + + useEffect(() => { + if (!gcode) { geometryRef.current = null framesRef.current = [] originalColorsRef.current = null prevProcessedLinesRef.current = 0 - return null + setGeometry(null) + return } - // Store frames and original colors for animation - framesRef.current = result.frames - const colorAttr = result.geometry.getAttribute('color') as BufferAttribute - originalColorsRef.current = colorAttr ? (colorAttr.array as Float32Array).slice() : null - prevProcessedLinesRef.current = 0 // Reset on new geometry - - // Apply offset if provided - if (offset && (offset.x !== 0 || offset.y !== 0 || offset.z !== 0)) { - const positionAttr = result.geometry.getAttribute('position') as BufferAttribute - const positions = positionAttr.array as Float32Array - const newPositions = new Float32Array(positions.length) - - for (let i = 0; i < positions.length; i += 3) { - newPositions[i] = positions[i] + offset.x - newPositions[i + 1] = positions[i + 1] + offset.y - newPositions[i + 2] = positions[i + 2] + offset.z + onLoadingChange?.(true) + + // Defer heavy processing to next frame so React can paint loading state + const timeoutId = setTimeout(() => { + const result = processGCode(gcode) + if (!result?.geometry) { + geometryRef.current = null + framesRef.current = [] + originalColorsRef.current = null + prevProcessedLinesRef.current = 0 + setGeometry(null) + onLoadingChange?.(false) + return } - const newGeometry = result.geometry.clone() - newGeometry.setAttribute('position', new BufferAttribute(newPositions, 3)) - // Clone color attribute as well to ensure we can update it + // Store frames and original colors for animation + framesRef.current = result.frames const colorAttr = result.geometry.getAttribute('color') as BufferAttribute - if (colorAttr) { - const colors = colorAttr.array as Float32Array - const clonedColors = colors.slice() - newGeometry.setAttribute('color', new BufferAttribute(clonedColors, 3)) - // Update originalColorsRef to point to the cloned colors - originalColorsRef.current = new Float32Array(clonedColors) + originalColorsRef.current = colorAttr ? (colorAttr.array as Float32Array).slice() : null + prevProcessedLinesRef.current = 0 // Reset on new geometry + + // Apply offset if provided + if (offset && (offset.x !== 0 || offset.y !== 0 || offset.z !== 0)) { + const positionAttr = result.geometry.getAttribute('position') as BufferAttribute + const positions = positionAttr.array as Float32Array + const newPositions = new Float32Array(positions.length) + + for (let i = 0; i < positions.length; i += 3) { + newPositions[i] = positions[i] + offset.x + newPositions[i + 1] = positions[i + 1] + offset.y + newPositions[i + 2] = positions[i + 2] + offset.z + } + + const newGeometry = result.geometry.clone() + newGeometry.setAttribute('position', new BufferAttribute(newPositions, 3)) + const cloneColorAttr = result.geometry.getAttribute('color') as BufferAttribute + if (cloneColorAttr) { + const colors = cloneColorAttr.array as Float32Array + const clonedColors = colors.slice() + newGeometry.setAttribute('color', new BufferAttribute(clonedColors, 3)) + originalColorsRef.current = new Float32Array(clonedColors) + } + geometryRef.current = newGeometry + setGeometry(newGeometry) + } else { + geometryRef.current = result.geometry + setGeometry(result.geometry) } - geometryRef.current = newGeometry - return newGeometry - } - geometryRef.current = result.geometry - return result.geometry - }, [ - gcode, - // Compare offset values instead of reference to prevent unnecessary recreation - offset, - ]) + onLoadingChange?.(false) + invalidate() + }, 0) + + return () => clearTimeout(timeoutId) + }, [gcode, offset, onLoadingChange, invalidate]) // eslint-disable-line react-hooks/exhaustive-deps // Update colors based on processed lines - incremental updates only paint the delta useEffect(() => { @@ -625,6 +644,8 @@ function CameraController({ xSize, ySize, zSize, view, viewKey }: { xSize: numbe export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machinePosition, modelOffset, processedLines, outlinePoints, vizMode = 'machine' }: VisualizerSceneProps = {}) { const { t } = useTranslation() const [webglAvailable, setWebglAvailable] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const handleLoadingChange = useCallback((loading: boolean) => setIsProcessing(loading), []) useEffect(() => { if (typeof window === 'undefined') { @@ -770,14 +791,24 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine )} {/* G-code toolpath visualization (shown in both modes) */} - + {/* Outline visualization - pink line showing toolpath boundary */} {outlinePoints && outlinePoints.length > 0 && ( )} - + + {/* Loading overlay */} + {isProcessing && ( +
+
+ + {t('Processing toolpath...')} +
+
+ )} + {/* Position readout overlay - only show if debug mode is enabled */} {debugMode && (
From 28a97ca51ba04c52a6dae2e4a840bc46b0c11eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:59:51 +0000 Subject: [PATCH 3/4] Fix loading indicator not showing during toolpath processing The previous approach managed loading state inside the R3F Canvas reconciler via a callback, but the outer DOM overlay never got a chance to paint before the heavy work blocked the main thread. Fix: manage the deferred gcode flow entirely in the outer DOM component (VisualizerScene). When new gcode arrives: 1. Set isProcessing=true and clear the deferred gcode immediately 2. Wait for double requestAnimationFrame (guarantees browser paint) 3. Only then pass the gcode to GCodeToolpath inside the Canvas This ensures the spinner overlay is visible before processGCode blocks the thread. GCodeToolpath reverts to synchronous useMemo since the deferral now happens at the parent level. https://claude.ai/code/session_01Y2PzWVYwZn4BTd37TWvEVB --- .../Setup/components/VisualizerScene.tsx | 145 ++++++++++-------- 1 file changed, 80 insertions(+), 65 deletions(-) diff --git a/apps/web/src/routes/Setup/components/VisualizerScene.tsx b/apps/web/src/routes/Setup/components/VisualizerScene.tsx index 569c84cf9..65d54bd29 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef, useState, useCallback } from 'react' +import { useMemo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ComponentRef } from 'react' import { Color } from 'three' @@ -217,7 +217,7 @@ function ZTopRectangle({ length, gridSizeX, gridSizeY }: { length: number; gridS } // G-code toolpath visualization component -function GCodeToolpath({ gcode, offset, processedLines = 0, onLoadingChange }: { gcode?: string | null; offset?: Vector3Type; processedLines?: number; onLoadingChange?: (loading: boolean) => void }) { +function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | null; offset?: Vector3Type; processedLines?: number }) { const geometryRef = useRef(null) const framesRef = useRef>([]) const originalColorsRef = useRef(null) @@ -225,74 +225,50 @@ function GCodeToolpath({ gcode, offset, processedLines = 0, onLoadingChange }: { const redColor = useMemo(() => new Color(1, 0, 0), []) // Red color for processed lines const invalidate = useThree((state) => state.invalidate) - // Process G-code asynchronously so loading indicator can paint before heavy work - const [geometry, setGeometry] = useState(null) - - useEffect(() => { - if (!gcode) { + const geometry = useMemo(() => { + const result = processGCode(gcode) + if (!result?.geometry) { geometryRef.current = null framesRef.current = [] originalColorsRef.current = null prevProcessedLinesRef.current = 0 - setGeometry(null) - return + return null } - onLoadingChange?.(true) - - // Defer heavy processing to next frame so React can paint loading state - const timeoutId = setTimeout(() => { - const result = processGCode(gcode) - if (!result?.geometry) { - geometryRef.current = null - framesRef.current = [] - originalColorsRef.current = null - prevProcessedLinesRef.current = 0 - setGeometry(null) - onLoadingChange?.(false) - return + // Store frames and original colors for animation + framesRef.current = result.frames + const colorAttr = result.geometry.getAttribute('color') as BufferAttribute + originalColorsRef.current = colorAttr ? (colorAttr.array as Float32Array).slice() : null + prevProcessedLinesRef.current = 0 // Reset on new geometry + + // Apply offset if provided + if (offset && (offset.x !== 0 || offset.y !== 0 || offset.z !== 0)) { + const positionAttr = result.geometry.getAttribute('position') as BufferAttribute + const positions = positionAttr.array as Float32Array + const newPositions = new Float32Array(positions.length) + + for (let i = 0; i < positions.length; i += 3) { + newPositions[i] = positions[i] + offset.x + newPositions[i + 1] = positions[i + 1] + offset.y + newPositions[i + 2] = positions[i + 2] + offset.z } - // Store frames and original colors for animation - framesRef.current = result.frames - const colorAttr = result.geometry.getAttribute('color') as BufferAttribute - originalColorsRef.current = colorAttr ? (colorAttr.array as Float32Array).slice() : null - prevProcessedLinesRef.current = 0 // Reset on new geometry - - // Apply offset if provided - if (offset && (offset.x !== 0 || offset.y !== 0 || offset.z !== 0)) { - const positionAttr = result.geometry.getAttribute('position') as BufferAttribute - const positions = positionAttr.array as Float32Array - const newPositions = new Float32Array(positions.length) - - for (let i = 0; i < positions.length; i += 3) { - newPositions[i] = positions[i] + offset.x - newPositions[i + 1] = positions[i + 1] + offset.y - newPositions[i + 2] = positions[i + 2] + offset.z - } - - const newGeometry = result.geometry.clone() - newGeometry.setAttribute('position', new BufferAttribute(newPositions, 3)) - const cloneColorAttr = result.geometry.getAttribute('color') as BufferAttribute - if (cloneColorAttr) { - const colors = cloneColorAttr.array as Float32Array - const clonedColors = colors.slice() - newGeometry.setAttribute('color', new BufferAttribute(clonedColors, 3)) - originalColorsRef.current = new Float32Array(clonedColors) - } - geometryRef.current = newGeometry - setGeometry(newGeometry) - } else { - geometryRef.current = result.geometry - setGeometry(result.geometry) + const newGeometry = result.geometry.clone() + newGeometry.setAttribute('position', new BufferAttribute(newPositions, 3)) + const cloneColorAttr = result.geometry.getAttribute('color') as BufferAttribute + if (cloneColorAttr) { + const colors = cloneColorAttr.array as Float32Array + const clonedColors = colors.slice() + newGeometry.setAttribute('color', new BufferAttribute(clonedColors, 3)) + originalColorsRef.current = new Float32Array(clonedColors) } + geometryRef.current = newGeometry + return newGeometry + } - onLoadingChange?.(false) - invalidate() - }, 0) - - return () => clearTimeout(timeoutId) - }, [gcode, offset, onLoadingChange, invalidate]) // eslint-disable-line react-hooks/exhaustive-deps + geometryRef.current = result.geometry + return result.geometry + }, [gcode, offset]) // Update colors based on processed lines - incremental updates only paint the delta useEffect(() => { @@ -644,8 +620,47 @@ function CameraController({ xSize, ySize, zSize, view, viewKey }: { xSize: numbe export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machinePosition, modelOffset, processedLines, outlinePoints, vizMode = 'machine' }: VisualizerSceneProps = {}) { 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. + const [deferredGcode, setDeferredGcode] = useState(gcode) const [isProcessing, setIsProcessing] = useState(false) - const handleLoadingChange = useCallback((loading: boolean) => setIsProcessing(loading), []) + + useEffect(() => { + if (!gcode) { + setDeferredGcode(null) + setIsProcessing(false) + return + } + + // Show loading overlay immediately + 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(gcode) + }) + }) + return () => { + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) + } + }, [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') { @@ -706,9 +721,9 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine // In WCS mode, compute bounding box from gcode to size the grid/camera const wcsGcodeResult = useMemo(() => { - if (vizMode !== 'wcs' || !gcode) return null - return processGCode(gcode) - }, [vizMode, gcode]) + if (vizMode !== 'wcs' || !deferredGcode) return null + return processGCode(deferredGcode) + }, [vizMode, deferredGcode]) const wcsSceneSize = useMemo(() => { if (!wcsGcodeResult?.boundingBox) return 200 @@ -791,7 +806,7 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine )} {/* G-code toolpath visualization (shown in both modes) */} - + {/* Outline visualization - pink line showing toolpath boundary */} {outlinePoints && outlinePoints.length > 0 && ( From 6e50f1e1cc3272063e5af3fcb4d24957476bbaee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 00:05:24 +0000 Subject: [PATCH 4/4] Fix loading indicator by deferring all heavy processing in VisualizerPanel The loading indicator was not visible because the heavy work (processGCode + concaveman hull calculation) ran synchronously in VisualizerPanel's event handlers and effects BEFORE VisualizerScene ever received the gcode. Fixes: - Remove duplicate calculateOutline calls from the WebSocket handler and API restore effect (they were redundant with the loadedGcode effect) - Defer the outline calculation effect with double requestAnimationFrame so the browser can paint the loading overlay before heavy work runs - Add isProcessingGcode state at VisualizerPanel level and pass it as isLoading prop to VisualizerScene - VisualizerScene shows the spinner when either external isLoading or its own internal deferred gcode processing is active https://claude.ai/code/session_01Y2PzWVYwZn4BTd37TWvEVB --- .../Setup/components/VisualizerScene.tsx | 7 +- .../routes/Setup/panels/VisualizerPanel.tsx | 81 ++++++++----------- 2 files changed, 39 insertions(+), 49 deletions(-) diff --git a/apps/web/src/routes/Setup/components/VisualizerScene.tsx b/apps/web/src/routes/Setup/components/VisualizerScene.tsx index 65d54bd29..aac0b51e8 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -24,6 +24,7 @@ interface VisualizerSceneProps { processedLines?: number // Number of G-code lines that have been processed (for animation) outlinePoints?: Array<{ x: number; y: number }> // Outline points to visualize vizMode?: VizMode // 'machine' = absolute envelope view (default), 'wcs' = relative to gcode WCS origin + isLoading?: boolean // External loading state (e.g. outline calculation in progress) } // Grid component - draws a grid on the z=0 plane, starting at origin and extending in positive X and Y @@ -617,7 +618,7 @@ function CameraController({ xSize, ySize, zSize, view, viewKey }: { xSize: numbe } // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machinePosition, modelOffset, processedLines, outlinePoints, vizMode = 'machine' }: VisualizerSceneProps = {}) { +export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machinePosition, modelOffset, processedLines, outlinePoints, vizMode = 'machine', isLoading = false }: VisualizerSceneProps = {}) { const { t } = useTranslation() const [webglAvailable, setWebglAvailable] = useState(null) @@ -814,8 +815,8 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine )} - {/* Loading overlay */} - {isProcessing && ( + {/* Loading overlay - shows during external processing (outline calc) or internal geometry parsing */} + {(isLoading || isProcessing) && (
diff --git a/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx b/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx index bf8072d78..959428bdc 100644 --- a/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx +++ b/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx @@ -263,6 +263,7 @@ export function VisualizerPanel({ const [loadedGcode, setLoadedGcode] = useState<{ name: string; gcode: string } | null>(null) const [modelOffset, setModelOffset] = useState<{ x: number; y: number; z: number } | null>(null) const [outlinePoints, setOutlinePoints] = useState(null) + const [isProcessingGcode, setIsProcessingGcode] = useState(false) // Read showOutline toggle from localStorage (managed by DebugPanel) const [showOutline, setShowOutline] = useState(() => { @@ -351,22 +352,8 @@ export function VisualizerPanel({ console.log('[VisualizerPanel] Restoring G-code from API:', gcodeData.name) setLoadedGcode({ name: gcodeData.name, gcode }) lastRestoredApiFileRef.current = gcodeData.name - - // Calculate outline for visualization - // Note: machinePosition is only used for generating commands, not for hull calculation - const currentMachinePosition = machinePositionRef.current - if (gcode && currentMachinePosition) { - const outlineResult = calculateOutline(gcode, currentMachinePosition, { - concavity: 5, - minPointDistance: 5, // 5mm minimum distance between points - }) - if (outlineResult) { - setOutlinePoints(outlineResult.hullPoints) - } else { - setOutlinePoints(null) - } - } - + // Outline calculation is handled by the loadedGcode?.gcode effect + // Try to restore model offset from localStorage const savedOffsetKey = `modelOffset_${gcodeData.name}` const savedOffset = localStorage.getItem(savedOffsetKey) @@ -406,22 +393,8 @@ export function VisualizerPanel({ // Only reset if this is a different file than the one we've already placed const isNewFile = placedGcodeRef.current !== name setLoadedGcode({ name, gcode }) - - // Calculate outline for visualization - // Note: machinePosition is only used for generating commands, not for hull calculation - const currentMachinePosition = machinePositionRef.current - if (currentMachinePosition) { - const outlineResult = calculateOutline(gcode, currentMachinePosition, { - concavity: 5, - minPointDistance: 5, // 5mm minimum distance between points - }) - if (outlineResult) { - setOutlinePoints(outlineResult.hullPoints) - } else { - setOutlinePoints(null) - } - } - + // Outline calculation is handled by the loadedGcode?.gcode effect + // Clear unload sentinel when a file is loaded if (lastRestoredApiFileRef.current === '') { lastRestoredApiFileRef.current = null @@ -457,22 +430,37 @@ export function VisualizerPanel({ }, []) // Empty deps - handlers use refs to access current values // Recalculate outline when G-code changes - // Note: machinePosition is only used for generating outline commands, not for hull calculation - // so we intentionally exclude it from deps to avoid recalculating on every position update + // Uses deferred processing so the loading indicator can paint before heavy work runs useEffect(() => { - if (loadedGcode?.gcode) { - const currentMachinePosition = machinePositionRef.current - const outlineResult = calculateOutline(loadedGcode.gcode, currentMachinePosition, { - concavity: 2, - minPointDistance: 1, // 1mm minimum distance between points - }) - if (outlineResult) { - setOutlinePoints(outlineResult.hullPoints) - } else { - setOutlinePoints(null) - } - } else { + if (!loadedGcode?.gcode) { setOutlinePoints(null) + setIsProcessingGcode(false) + return + } + + setIsProcessingGcode(true) + + // Double rAF ensures the browser has painted the loading overlay before heavy work + let rafId1 = 0 + let rafId2 = 0 + rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + const currentMachinePosition = machinePositionRef.current + const outlineResult = calculateOutline(loadedGcode.gcode, currentMachinePosition, { + concavity: 2, + minPointDistance: 1, // 1mm minimum distance between points + }) + if (outlineResult) { + setOutlinePoints(outlineResult.hullPoints) + } else { + setOutlinePoints(null) + } + setIsProcessingGcode(false) + }) + }) + return () => { + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) } }, [loadedGcode?.gcode]) // eslint-disable-line react-hooks/exhaustive-deps @@ -707,6 +695,7 @@ export function VisualizerPanel({ modelOffset={vizMode === 'machine' ? modelOffsetVector3 : undefined} outlinePoints={showOutline ? (outlinePoints || undefined) : undefined} vizMode={vizMode} + isLoading={isProcessingGcode} /> {/* View controls overlay */}