/** * G-code preview drawn the way the desktop slicer draws it. * * The renderer under this is OrcaSlicer's own `libvgcode`, vendored via * `three-slicer` (see `src/lib/vendor/toolpathRenderer.js`): each extrusion is a * diamond-section prism instanced once per segment, so a whole print is a * single indexed draw call and the toolpath occludes itself. The previous * viewer drew screen-space lines, which have no thickness in the scene and * therefore cannot hide the layer behind them -- the reason a sliced model * came out stringy and shimmering. * * The other half of the difference is colour. This colours by *feature* -- * wall, infill, support, bridge -- from the `;TYPE:` annotations the slicer * writes, which is what makes a preview readable. Colouring by filament, as * the old viewer did, paints AMS slot colours across the whole print and tells * you nothing about what the printer is doing. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { Loader2, FileWarning } from 'lucide-react'; import { getAuthToken } from '../api/client'; import { parseGcodeToolpath, layersByFilament, filterLayersByType, ToolpathType, type ParsedToolpath, } from '../lib/gcodeToolpath'; // Typed by the sibling toolpathRenderer.d.ts. import { buildSegmentData, makeToolpath, computeColors, TYPE_COLOR, DEFAULT_RANGES_COLORS, } from '../lib/vendor/toolpathRenderer.js'; interface GcodeToolpathViewerProps { gcodeUrl: string; buildVolume?: { x: number; y: number; z: number }; /** * AMS slot colours, in tool order. When supplied the viewer opens on a * filament-coloured view, which is what a multi-material print is usually * being looked at for -- feature colouring answers a different question. */ filamentColors?: string[]; className?: string; } /** * The colour modes worth offering. * * Upstream also exposes speed, fan and temperature, but its own implementation * derives those from *settings* rather than the toolpath, because its slicing * kernel doesn't expose them per segment. Reading them out of the G-code would * give real values -- `F`, `M106` and `M104` are right there in the file -- so * they are left out until the parser carries them, rather than shipped as * plausible-looking guesses. */ const VIEW_MODES = ['filament', 'feature', 'height', 'width'] as const; type ViewMode = (typeof VIEW_MODES)[number]; /** Feature rows for the legend, in the order the slicer lists them. */ const LEGEND_ENTRIES: Array<{ type: number; key: string; fallback: string }> = [ { type: ToolpathType.wall, key: 'gcodeViewer.feature.wall', fallback: 'Walls' }, { type: ToolpathType.sparseInfill, key: 'gcodeViewer.feature.sparseInfill', fallback: 'Sparse infill' }, { type: ToolpathType.solidInfill, key: 'gcodeViewer.feature.solidInfill', fallback: 'Solid infill' }, { type: ToolpathType.bridge, key: 'gcodeViewer.feature.bridge', fallback: 'Bridge / overhang' }, { type: ToolpathType.support, key: 'gcodeViewer.feature.support', fallback: 'Support' }, { type: ToolpathType.skirt, key: 'gcodeViewer.feature.skirt', fallback: 'Skirt / brim' }, { type: ToolpathType.gapFill, key: 'gcodeViewer.feature.gapFill', fallback: 'Gap fill' }, { type: ToolpathType.ironing, key: 'gcodeViewer.feature.ironing', fallback: 'Ironing' }, { type: ToolpathType.primeTower, key: 'gcodeViewer.feature.primeTower', fallback: 'Prime tower' }, ]; /** * Pack a CSS hex colour the way the renderer expects. * * It stores colour as a single float holding `r << 16 | g << 8 | b`, which its * shader unpacks. Matching that exactly is what lets filament colours be * applied through the same `setColors` path the built-in views use. */ function packColor(hex: string): number { const value = hex.replace('#', ''); const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value; const n = Number.parseInt(full.slice(0, 6), 16); return Number.isFinite(n) ? n : 0x00ae42; } const cssColor = (rgb: number[] | undefined): string => rgb ? `rgb(${rgb.map((c) => Math.round(c * 255)).join(',')})` : 'transparent'; /** The renderer's own blue-to-red ramp, as CSS gradient stops. */ function rampStops(): string { const colors = DEFAULT_RANGES_COLORS as number[][]; return colors .map((rgb, i) => `${cssColor(rgb)} ${((i / (colors.length - 1)) * 100).toFixed(0)}%`) .join(', '); } /** Two decimals for a layer height, none for a large speed-like value. */ function formatScale(value: number): string { if (!Number.isFinite(value)) return '-'; return Math.abs(value) < 10 ? value.toFixed(2) : value.toFixed(0); } export function GcodeToolpathViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '', }: GcodeToolpathViewerProps) { const { t } = useTranslation(); const containerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [notSliced, setNotSliced] = useState(false); const [parsed, setParsed] = useState(null); const hasFilamentColors = (filamentColors?.length ?? 0) > 0; const [viewMode, setViewMode] = useState(hasFilamentColors ? 'filament' : 'feature'); // Colours are fetched, so they usually arrive after the first render and the // initial state above lands on 'feature'. Adopt filament when they turn up, // unless the user has already picked a mode for themselves. const modeChosenRef = useRef(false); useEffect(() => { if (hasFilamentColors && !modeChosenRef.current) setViewMode('filament'); }, [hasFilamentColors]); // Filament and feature colouring merge vertices differently, so they cannot // share one built mesh; the toolpath is rebuilt when crossing between them. const [layerRange, setLayerRange] = useState<[number, number]>([0, 0]); // Hidden types, tracked separately per colour space: in filament view a // "type" is a filament slot, in every other view it is a feature. const [hiddenFeatures, setHiddenFeatures] = useState>(new Set()); const [hiddenFilaments, setHiddenFilaments] = useState>(new Set()); const filamentView = viewMode === 'filament'; const hidden = filamentView ? hiddenFilaments : hiddenFeatures; // A stable key so the toolpath effect re-runs on a change of contents rather // than on every new Set identity. const hiddenKey = [...hidden].sort((a, b) => a - b).join(','); const toggleHidden = (type: number) => { const update = (prev: ReadonlySet) => { const next = new Set(prev); if (next.has(type)) next.delete(type); else next.add(type); return next; }; if (filamentView) setHiddenFilaments(update); else setHiddenFeatures(update); }; const [showTravel, setShowTravel] = useState(false); // Kept out of state: these are three.js objects, and re-rendering React on // every camera nudge would be pointless work. const sceneRef = useRef(null); const cameraRef = useRef(null); const controlsRef = useRef(null); const handleRef = useRef | null>(null); const segmentDataRef = useRef | null>(null); // Bumped when a new toolpath is built, so the colour effect re-runs against // it -- the data it colours lives in a ref rather than in state. const [toolpathGeneration, setToolpathGeneration] = useState(0); // Read inside the toolpath effect without making it a dependency: a rebuild // should honour the current controls, not reset them, and re-running on // every slider nudge would rebuild the whole mesh. const showTravelRef = useRef(showTravel); const layerRangeRef = useRef(layerRange); showTravelRef.current = showTravel; layerRangeRef.current = layerRange; // The camera is framed once. Re-framing on a colour-mode switch would yank // the view back from wherever the user had put it. const framedRef = useRef(false); // `buildVolume` defaults to an object literal, so without this every render // produced a new identity. That identity was a dependency of the scene // effect, which therefore tore down and rebuilt the WebGL renderer on every // render -- and browsers cap live WebGL contexts at around sixteen, dropping // the oldest, which is why the canvas went blank after a few interactions. const volumeKey = `${buildVolume.x}x${buildVolume.y}x${buildVolume.z}`; const volume = useMemo( () => ({ x: buildVolume.x, y: buildVolume.y, z: buildVolume.z }), // eslint-disable-next-line react-hooks/exhaustive-deps [volumeKey], ); // --- Fetch and parse ----------------------------------------------------- useEffect(() => { let cancelled = false; setLoading(true); setError(null); setNotSliced(false); setParsed(null); const headers: HeadersInit = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; fetch(gcodeUrl, { headers }) .then(async (response) => { if (!response.ok) { if (response.status === 404) { const data = await response.json().catch(() => ({})); if (typeof data.detail === 'string' && data.detail.includes('sliced')) { setNotSliced(true); throw new Error('not_sliced'); } } throw new Error('Failed to load G-code'); } return response.text(); }) .then((gcode) => { if (cancelled) return; framedRef.current = false; const result = parseGcodeToolpath(gcode); setParsed(result); setLayerRange([0, Math.max(0, result.layers.length - 1)]); setLoading(false); }) .catch((err: Error) => { if (cancelled) return; if (err.message !== 'not_sliced') setError(err.message); setLoading(false); }); return () => { cancelled = true; }; }, [gcodeUrl]); // --- Scene: created once, never rebuilt ---------------------------------- // Deliberately independent of the toolpath. Tearing the renderer down to // recolour would leak WebGL contexts and throw away the camera the user had // positioned. useEffect(() => { const container = containerRef.current; if (!container) return; const width = container.clientWidth || 1; const height = container.clientHeight || 1; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); sceneRef.current = scene; const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000); cameraRef.current = camera; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Take the canvas out of flow before it is ever in the document (#2887). // `setSize` writes the size onto the canvas as inline width/height, and the // canvas lives inside the very element we measure and observe — so on a page // where that element's height comes from its content, each resize grew the // container, which fired the observer, which resized again. three.js leaves // the canvas `display: inline`, so the line box added its descender space // (~33px) every round and the page climbed without limit. Out of flow it // cannot contribute to the container's height at all; `display: block` is // belt and braces for the same descender, and matters if this is ever // rendered somewhere the absolute positioning is overridden. renderer.domElement.style.display = 'block'; renderer.domElement.style.position = 'absolute'; renderer.domElement.style.inset = '0'; container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controlsRef.current = controls; // The bed. The toolpath shader lights itself (libvgcode carries its own // light directions), so the scene needs no lights at all. const grid = new THREE.GridHelper( Math.max(volume.x, volume.y), Math.ceil(Math.max(volume.x, volume.y) / 16), 0x444444, 0x333333, ); // The toolpath group is rotated -90 degrees about X to take the slicer's // Z-up space into three's Y-up, which maps (x, y, z) to (x, z, -y) -- so // the bed's +Y runs along world -Z. Placing the grid at +Z left the print // sitting beside its own plate rather than on it. grid.position.set(volume.x / 2, 0, -volume.y / 2); scene.add(grid); let frame = 0; const animate = () => { frame = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); const handleResize = () => { const w = container.clientWidth || 1; const h = container.clientHeight || 1; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }; const observer = new ResizeObserver(handleResize); observer.observe(container); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); observer.disconnect(); cancelAnimationFrame(frame); controls.dispose(); grid.geometry.dispose(); (grid.material as THREE.Material).dispose(); renderer.dispose(); container.removeChild(renderer.domElement); sceneRef.current = null; cameraRef.current = null; controlsRef.current = null; }; }, [volume]); // --- Toolpath: rebuilt when the colouring changes its vertex layout ------ useEffect(() => { const scene = sceneRef.current; const camera = cameraRef.current; const controls = controlsRef.current; if (!scene || !camera || !controls || !parsed || parsed.layers.length === 0) return; // Filament and feature colouring merge adjacent vertices differently, so // they genuinely produce different vertex streams and cannot share a mesh. const keyed = filamentView ? layersByFilament(parsed.layers) : parsed.layers; const sourceLayers = filterLayersByType(keyed, hidden); const data = buildSegmentData(sourceLayers, parsed.defaultWidth); const handle = makeToolpath(THREE, data); segmentDataRef.current = data; handleRef.current = handle; const group = new THREE.Group(); group.rotation.x = -Math.PI / 2; group.add(handle.mesh); group.add(handle.travLines); scene.add(group); handle.setTravelVisible(showTravelRef.current); handle.setLayerRange(layerRangeRef.current[0], layerRangeRef.current[1]); setToolpathGeneration((n) => n + 1); // Frame only on first build, so switching colour mode does not yank the // camera back from wherever the user put it. if (!framedRef.current) { framedRef.current = true; // Not Box3.setFromObject: this renderer keeps segment positions in a // data texture, and the geometry attribute is only the 8-vertex diamond // template -- measuring the object reports a few millimetres, so the // camera parked itself far away and the print came out tiny. const b = parsed.bounds; const box = b ? new THREE.Box3( new THREE.Vector3(b.min[0], b.min[2], -b.max[1]), new THREE.Vector3(b.max[0], b.max[2], -b.min[1]), ) : new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(volume.x, 1, -volume.y)); const center = box.getCenter(new THREE.Vector3()); const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.001); const vFov = THREE.MathUtils.degToRad(camera.fov); const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect); const distance = 1.15 * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2)); camera.position.copy(center).addScaledVector(new THREE.Vector3(0.7, 0.5, 0.7).normalize(), distance); camera.near = Math.max(distance / 1000, 0.01); camera.far = distance + radius * 4; camera.updateProjectionMatrix(); controls.target.copy(center); controls.update(); } return () => { scene.remove(group); // The handle owns instanced buffers and a data texture per segment; on a // large print that is a lot of GPU memory to leave behind. handle.dispose(); handleRef.current = null; segmentDataRef.current = null; }; // hiddenKey rather than the Set, whose identity changes on every toggle. // eslint-disable-next-line react-hooks/exhaustive-deps }, [parsed, filamentView, volume, hiddenKey]); // --- Controls drive the existing handle rather than rebuilding it --------- useEffect(() => { handleRef.current?.setLayerRange(layerRange[0], layerRange[1]); }, [layerRange]); useEffect(() => { handleRef.current?.setTravelVisible(showTravel); }, [showTravel]); const colorResult = useMemo(() => { const data = segmentDataRef.current; if (!data || !parsed) return null; if (filamentView) { // In this mode each vertex's "type" is its filament index + 1, so the // AMS colours can be applied straight from the per-vertex metadata. const colors = new Float32Array(data.nV * 4); for (let v = 0; v < data.nV; v += 1) { const slot = Math.max(0, data.meta.vType[v] - 1); const hex = filamentColors?.[slot] ?? filamentColors?.[0] ?? '#00ae42'; colors[v * 4] = packColor(hex); } return { color: colors, min: 0, max: 0, unit: '', cont: false }; } // feature / height / width never consult the settings context, so an empty // one is honest here; speed / fan / temp would not be, which is why they // are not offered. return computeColors(data, viewMode, {}); // toolpathGeneration is a dependency so a rebuilt mesh gets recoloured. // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewMode, parsed, filamentView, filamentColors, toolpathGeneration]); useEffect(() => { if (colorResult) handleRef.current?.setColors(colorResult.color); }, [colorResult]); const layerCount = parsed?.layers.length ?? 0; if (notSliced) { return (
{t('gcodeViewer.notSliced', 'This file has not been sliced yet.')}
); } if (error) { return (
{t('gcodeViewer.loadFailed', 'Could not load the G-code for this file.')}
); } return (
{/* `overflow-hidden` (#3371): `setSize` writes the canvas width inline and only runs from the observer, so for one round the canvas keeps the old width. An absolute box that overflows still counts towards the scroll area of `
`, so on a page exactly its height the 15px of a scrollbar toggled on and off forever and nothing was drawn. */} {/* Absolute, not `w-full h-full` (#2887). The canvas is appended here and this element is what the ResizeObserver watches, so its height must come from the pane above it and never from what it contains. `h-full` is a percentage, which resolves to `auto` unless every ancestor has a definite height — on the full-page route none does, so the height fell through to the content and the canvas ended up sizing the box that sizes the canvas. `inset-0` against the `relative` parent is a definite height whatever the page does, which also keeps this working if a future caller forgets to give the pane a height of its own. */}
{loading && (
{t('gcodeViewer.loading', 'Reading toolpath...')}
)} {!loading && layerCount > 0 && ( <> {/* Colour mode + travel toggle */}
{VIEW_MODES.filter((mode) => mode !== 'filament' || hasFilamentColors).map((mode) => ( ))}
{filamentView ? (
    {(filamentColors ?? []).map((color, slot) => { // Filament view keys types as slot + 1; see layersByFilament. const type = slot + 1; const isHidden = hidden.has(type); return (
  • ); })}
) : viewMode === 'feature' ? (
    {LEGEND_ENTRIES.map((entry) => { const isHidden = hidden.has(entry.type); return (
  • ); })}
) : ( colorResult && ( // Continuous scale. The ramp is drawn from the renderer's own // stops rather than an approximation, and the ends are labelled // -- a bare gradient says nothing about what the colours mean.
{formatScale(colorResult.min)} {colorResult.unit} {formatScale(colorResult.max)}
) )}
{/* Layer range. Two ends, because inspecting a print means isolating a band of layers, not just capping the top. */}
{layerRange[1] + 1} { const top = Number(e.target.value); setLayerRange(([bottom]) => [Math.min(bottom, top), top]); }} aria-label={t('gcodeViewer.topLayer', 'Top layer')} className="h-40 w-4 cursor-pointer" style={{ writingMode: 'vertical-lr', direction: 'rtl' }} /> { const bottom = Number(e.target.value); setLayerRange(([, top]) => [bottom, Math.max(bottom, top)]); }} aria-label={t('gcodeViewer.bottomLayer', 'Bottom layer')} className="h-40 w-4 cursor-pointer" style={{ writingMode: 'vertical-lr', direction: 'rtl' }} /> {layerRange[0] + 1}
)}
); }