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
22 changes: 19 additions & 3 deletions apps/web/src/lib/gcodeVisualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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[] = []
Expand Down Expand Up @@ -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
}
175 changes: 121 additions & 54 deletions apps/web/src/routes/Setup/components/VisualizerScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -220,21 +222,25 @@ function GCodeToolpath({ gcode, offset, processedLines = 0 }: { gcode?: string |
const geometryRef = useRef<BufferGeometry | null>(null)
const framesRef = useRef<Array<{ data: string; vertexIndex: number }>>([])
const originalColorsRef = useRef<Float32Array | null>(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)
if (!result?.geometry) {
geometryRef.current = null
framesRef.current = []
originalColorsRef.current = null
prevProcessedLinesRef.current = 0
return null
}

// 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)) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(() => {
Expand Down Expand Up @@ -407,23 +426,18 @@ function ToolIndicator({ position = [0, 0, 50] }: { position?: [number, number,
function BillboardText({ position, children, fontSize = 20, ...props }: React.ComponentProps<typeof Text>) {
const groupRef = useRef<Group>(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)
}
})
Expand Down Expand Up @@ -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<ComponentRef<typeof OrbitControls>>(null)

const gridCenterX = xSize / 2
Expand Down Expand Up @@ -585,27 +599,70 @@ 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 (
<OrbitControls
ref={controlsRef}
enableDamping
dampingFactor={0.05}
target={[gridCenterX, gridCenterY, gridCenterZ]}
minDistance={1}
<OrbitControls
ref={controlsRef}
enableDamping
dampingFactor={0.05}
target={[gridCenterX, gridCenterY, gridCenterZ]}
minDistance={1}
maxDistance={maxGridSize * 3}
onChange={() => 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
/>
)
}

// 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<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.
const [deferredGcode, setDeferredGcode] = useState<string | null | undefined>(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)
Expand Down Expand Up @@ -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
Expand All @@ -690,7 +747,7 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine

return (
<div className="relative w-full h-full">
<Canvas>
<Canvas frameloop="demand">
{/* Camera setup - positioned to see the full grid */}
<PerspectiveCamera
makeDefault
Expand Down Expand Up @@ -750,14 +807,24 @@ export function VisualizerScene({ gcode, limits: _limits, view, viewKey, machine
)}

{/* G-code toolpath visualization (shown in both modes) */}
<GCodeToolpath gcode={gcode} offset={modelOffset} processedLines={processedLines} />
<GCodeToolpath gcode={deferredGcode} offset={modelOffset} processedLines={processedLines} />

{/* Outline visualization - pink line showing toolpath boundary */}
{outlinePoints && outlinePoints.length > 0 && (
<OutlinePath points={outlinePoints} offset={modelOffset} zHeight={machinePosition?.z ? machinePosition.z + 5 : 5} />
)}
</Canvas>


{/* Loading overlay - shows during external processing (outline calc) or internal geometry parsing */}
{(isLoading || isProcessing) && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 pointer-events-none z-10">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
{t('Processing toolpath...')}
</div>
</div>
)}

{/* Position readout overlay - only show if debug mode is enabled */}
{debugMode && (
<div className="absolute top-2 right-2 bg-black/70 text-white text-xs font-mono rounded px-2 py-1.5 pointer-events-none">
Expand Down
Loading
Loading