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..aac0b51e8 100644 --- a/apps/web/src/routes/Setup/components/VisualizerScene.tsx +++ b/apps/web/src/routes/Setup/components/VisualizerScene.tsx @@ -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' @@ -23,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 @@ -220,7 +222,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 +232,7 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | geometryRef.current = null framesRef.current = [] originalColorsRef.current = null + prevProcessedLinesRef.current = 0 return null } @@ -235,6 +240,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)) { @@ -250,13 +256,11 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | const newGeometry = result.geometry.clone() newGeometry.setAttribute('position', new BufferAttribute(newPositions, 3)) - // Clone color attribute as well to ensure we can update it - const colorAttr = result.geometry.getAttribute('color') as BufferAttribute - if (colorAttr) { - const colors = colorAttr.array as Float32Array + 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)) - // Update originalColorsRef to point to the cloned colors originalColorsRef.current = new Float32Array(clonedColors) } geometryRef.current = newGeometry @@ -265,13 +269,9 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string | geometryRef.current = result.geometry return result.geometry - }, [ - gcode, - // Compare offset values instead of reference to prevent unnecessary recreation - offset, - ]) + }, [gcode, 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 +287,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 +426,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 +507,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 +599,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 /> @@ -602,10 +618,51 @@ 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) + // 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) + + 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') { setWebglAvailable(false) @@ -665,9 +722,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 @@ -690,7 +747,7 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine return (
- + {/* Camera setup - positioned to see the full grid */} + {/* Outline visualization - pink line showing toolpath boundary */} {outlinePoints && outlinePoints.length > 0 && ( )} - + + {/* Loading overlay - shows during external processing (outline calc) or internal geometry parsing */} + {(isLoading || isProcessing) && ( +
+
+ + {t('Processing toolpath...')} +
+
+ )} + {/* Position readout overlay - only show if debug mode is enabled */} {debugMode && (
diff --git a/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx b/apps/web/src/routes/Setup/panels/VisualizerPanel.tsx index 7ac2fba7b..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,23 +430,39 @@ 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 don't need to recalculate when the toolhead moves + // Uses deferred processing so the loading indicator can paint before heavy work runs useEffect(() => { - if (loadedGcode?.gcode && machinePosition) { - const outlineResult = calculateOutline(loadedGcode.gcode, machinePosition, { - 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, machinePosition]) + }, [loadedGcode?.gcode]) // eslint-disable-line react-hooks/exhaustive-deps // Automatically place model at WCS origin when G-code is loaded useEffect(() => { @@ -706,6 +695,7 @@ export function VisualizerPanel({ modelOffset={vizMode === 'machine' ? modelOffsetVector3 : undefined} outlinePoints={showOutline ? (outlinePoints || undefined) : undefined} vizMode={vizMode} + isLoading={isProcessingGcode} /> {/* View controls overlay */}