From 1e55a477a30628c8155985bd19821cf5fdf726af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Mon, 7 Sep 2026 23:27:29 -0400 Subject: [PATCH 1/2] Upgrade to gcode-preview 3.0.0-alpha.5 - `import * as GCodePreview` + `GCodePreview.init()` -> named `{ GCodePreview }` + `new GCodePreview()`; the component function keeps the `GCodePreviewUI` name so the class and the component no longer collide. - `WebGLPreview` type is gone: the preview instance is now typed `GCodePreview` and held in a ref (the old state-based handle captured a stale `undefined`). - `allowDragNDrop` -> `droppable`, `preview.resize()` -> `preview.sceneManager.resize()`, `preview.layers.length` -> `preview.countLayers`. - Replace the hand-rolled setTimeout chunk loader (and the `window[id]` timer stash it needed, plus the global `Window` index-signature augmentation) with `processGCodeStream()`, which parses and renders progressively on its own. The response body is decoded with `TextDecoderStream` first: the parser splits chunks on newlines and silently renders nothing when fed raw bytes. - Loads are guarded by an AbortController so a superseded fetch cannot stream into a new job, and the preview is disposed on unmount. - `@types/three` ^0.144.0 -> ^0.169.0 to match the `three` 0.169.0 that gcode-preview resolves to (single three instance, verified with `npm ls`). Assisted by Claude Code - Opus 5 --- package-lock.json | 75 +++++++++++++++++++-------- package.json | 4 +- src/App.tsx | 89 +++++++++++++-------------------- src/components/GCodePreview.tsx | 68 +++++++++++-------------- src/types/index.d.ts | 8 --- tsconfig.json | 2 +- 6 files changed, 121 insertions(+), 125 deletions(-) delete mode 100644 src/types/index.d.ts diff --git a/package-lock.json b/package-lock.json index 5847e57..26f7ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "react-typescript", "version": "1.0.0", "dependencies": { - "gcode-preview": "2.18", + "gcode-preview": "3.0.0-alpha.5", "react": "18.0.0", "react-dom": "18.0.0", "react-scripts": "5.0.1", @@ -17,7 +17,7 @@ "devDependencies": { "@types/react": "17.0.20", "@types/react-dom": "^18.0.6", - "@types/three": "^0.144.0", + "@types/three": "^0.169.0", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", "eslint-config-prettier": "^8.5.0", @@ -3436,6 +3436,13 @@ "node": ">=10.13.0" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3736,13 +3743,26 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/three": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.144.0.tgz", - "integrity": "sha512-psvEs6q5rLN50jUYZ3D4pZMfxTbdt3A243blt0my7/NcL6chaCZpHe2csbCtx0SOD9fI/XnF3wnVUAYZGqCSYg==", + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", "dev": true, + "license": "MIT", "dependencies": { - "@types/webxr": "*" + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" } }, "node_modules/@types/trusted-types": { @@ -4111,6 +4131,13 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -7515,6 +7542,13 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7926,21 +7960,15 @@ } }, "node_modules/gcode-preview": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz", - "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==", + "version": "3.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-3.0.0-alpha.5.tgz", + "integrity": "sha512-DWkw6Kv83QC7BxPHqhNk0dso9ILNgH/J5Mz+xvx7j4jqjCIg33xRmU9rkdmdlC6K1uduPVF5cb50LpA1TBLr6A==", "license": "MIT", "dependencies": { - "lil-gui": "^0.19.2", - "three": "^0.159.0" + "lil-gui": "^0.20.0", + "three": ">=0.166.0 <0.186.0" } }, - "node_modules/gcode-preview/node_modules/three": { - "version": "0.159.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.159.0.tgz", - "integrity": "sha512-eCmhlLGbBgucuo4VEA9IO3Qpc7dh8Bd4VKzr7WfW4+8hMcIfoAVi1ev0pJYN9PTTsCslbcKgBwr2wNZ1EvLInA==", - "license": "MIT" - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10334,9 +10362,9 @@ } }, "node_modules/lil-gui": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz", - "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.20.0.tgz", + "integrity": "sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==", "license": "MIT" }, "node_modules/lilconfig": { @@ -10527,6 +10555,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", diff --git a/package.json b/package.json index 69a5392..f284393 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "main": "src/index.tsx", "dependencies": { - "gcode-preview": "2.18", + "gcode-preview": "3.0.0-alpha.5", "react": "18.0.0", "react-dom": "18.0.0", "react-scripts": "5.0.1", @@ -18,7 +18,7 @@ "devDependencies": { "@types/react": "17.0.20", "@types/react-dom": "^18.0.6", - "@types/three": "^0.144.0", + "@types/three": "^0.169.0", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", "eslint-config-prettier": "^8.5.0", diff --git a/src/App.tsx b/src/App.tsx index ace34bb..e25011f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,71 +4,50 @@ import './styles.css'; type GCodePreviewHandle = React.ElementRef; -const chunkSize = 250; +// gcode-preview 3.x reads the stream itself and renders progressively while it +// parses, so no manual chunking is needed. The stream has to be decoded to text +// first: the parser splits chunks on '\n' and would silently render nothing if +// it were handed raw bytes. +const loadGcode = async ( + target: React.RefObject, + url: string, + signal: AbortSignal +) => { + const response = await fetch(url, { signal }); + if (!response.ok || !response.body) { + throw new Error( + `status code: ${response.status}, status text: ${response.statusText}` + ); + } + // the load may have been superseded while the file was in flight + if (signal.aborted) return; + await target.current?.load( + response.body.pipeThrough(new TextDecoderStream()) + ); +}; function App(): JSX.Element { const gcodePreviewRef1 = useRef(null); const gcodePreviewRef2 = useRef(null); // const [layersLoaded, setLayerLoaded] = useState(); - const fetchGcode = async (url: string) => { - const response = await fetch(url); - if (response.status >= 200 && response.status <= 299) { - const file = await response.text(); - return file.split('\n'); - } else { - const errorMessage = `status code: ${response.status}, status text: ${response.statusText}`; - throw new Error(errorMessage); - } - }; - - const loadPreviewChunked = ( - target: GCodePreviewHandle, - lines: string[], - delay: number - ) => { - let c = 0; - - // replace substr with substring because it is not part of the main ECMAScript specification - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substr - const id = - '__animationTimer__' + Math.random().toString(36).substring(2, 10); - const loadProgressive = () => { - const start = c * chunkSize; - const end = (c + 1) * chunkSize; - const chunk = lines.slice(start, end); - - target.processGCode(chunk); - // setLayerLoaded(target.getLayerCount()); - c++; - if (c * chunkSize < lines.length) { - window[id] = setTimeout(loadProgressive, delay); - } - }; - - // cancel loading process if one is still in progress - // mostly when hot reloading - window.clearTimeout(window[id]); - loadProgressive(); - }; - useEffect(() => { - async function init() { - const lines1 = await fetchGcode('/benchy.gcode'); - loadPreviewChunked( - gcodePreviewRef1.current as GCodePreviewHandle, - lines1, - 50 - ); + const controller = new AbortController(); - const lines2 = await fetchGcode('/duplo_tracks.gcode'); - loadPreviewChunked( - gcodePreviewRef2.current as GCodePreviewHandle, - lines2, - 50 + async function init() { + await loadGcode(gcodePreviewRef1, '/benchy.gcode', controller.signal); + await loadGcode( + gcodePreviewRef2, + '/duplo_tracks.gcode', + controller.signal ); } - init(); + + init().catch(err => { + if ((err as Error).name !== 'AbortError') console.error(err); + }); + + return () => controller.abort(); }, []); return ( diff --git a/src/components/GCodePreview.tsx b/src/components/GCodePreview.tsx index 9276acf..02d73e8 100644 --- a/src/components/GCodePreview.tsx +++ b/src/components/GCodePreview.tsx @@ -1,13 +1,5 @@ -import * as GCodePreview from 'gcode-preview'; -import { - forwardRef, - Ref, - useEffect, - useImperativeHandle, - useRef, - useState -} from 'react'; -import * as THREE from 'three'; +import { GCodePreview } from 'gcode-preview'; +import { forwardRef, Ref, useEffect, useImperativeHandle, useRef } from 'react'; interface GCodePreviewProps { topLayerColor?: string; @@ -19,55 +11,53 @@ interface GCodePreviewProps { interface GCodePreviewHandle { getLayerCount: () => number; - processGCode: (gcode: string | string[]) => void; + load: (gcode: string | string[] | ReadableStream) => Promise; } function GCodePreviewUI( props: GCodePreviewProps, ref: Ref ): JSX.Element { - const { - topLayerColor = '', - lastSegmentColor = '', - startLayer, - endLayer, - lineWidth - } = props; + const { topLayerColor, lastSegmentColor, startLayer, endLayer, lineWidth } = + props; const canvasRef = useRef(null); - const [preview, setPreview] = useState(); - - const resizePreview = () => { - preview?.resize(); - }; + const previewRef = useRef(null); useImperativeHandle(ref, () => ({ getLayerCount() { - return preview?.layers.length as number; + return previewRef.current?.countLayers as number; }, - processGCode(gcode) { - preview?.processGCode(gcode); + async load(gcode) { + const preview = previewRef.current; + if (!preview) return; + // state persists between loads, so start from a clean job. + // clear() also cancels a stream that is still being read. + preview.clear(); + await preview.processGCodeStream(gcode); } })); useEffect(() => { - setPreview( - GCodePreview.init({ - canvas: canvasRef.current as HTMLCanvasElement, - startLayer, - endLayer, - lineWidth, - topLayerColor: new THREE.Color(topLayerColor).getHex(), - lastSegmentColor: new THREE.Color(lastSegmentColor).getHex(), - buildVolume: { x: 250, y: 220, z: 150 }, - initialCameraPosition: [0, 400, 450], - allowDragNDrop: false - }) - ); + const preview = new GCodePreview({ + canvas: canvasRef.current as HTMLCanvasElement, + startLayer, + endLayer, + lineWidth, + topLayerColor, + lastSegmentColor, + buildVolume: { x: 250, y: 220, z: 150, smallGrid: false }, + initialCameraPosition: [0, 400, 450], + droppable: false + }); + previewRef.current = preview; + const resizePreview = () => preview.sceneManager.resize(); window.addEventListener('resize', resizePreview); return () => { window.removeEventListener('resize', resizePreview); + previewRef.current = null; + preview.dispose(); }; }, []); diff --git a/src/types/index.d.ts b/src/types/index.d.ts deleted file mode 100644 index 9393840..0000000 --- a/src/types/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export {}; - -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; - } -} diff --git a/tsconfig.json b/tsconfig.json index 66a873b..ae62e4d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "typeRoots": ["./node_modules/@types", "./src/types"] + "typeRoots": ["./node_modules/@types"] } } From 21fe28bfa88831f29e85cd3868f9dbdb16f8dc0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Mon, 7 Sep 2026 23:50:24 -0400 Subject: [PATCH 2/2] Pin gcode-preview to 3.0.0-alpha.6 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26f7ffe..073639d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "react-typescript", "version": "1.0.0", "dependencies": { - "gcode-preview": "3.0.0-alpha.5", + "gcode-preview": "3.0.0-alpha.6", "react": "18.0.0", "react-dom": "18.0.0", "react-scripts": "5.0.1", @@ -7960,9 +7960,9 @@ } }, "node_modules/gcode-preview": { - "version": "3.0.0-alpha.5", - "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-3.0.0-alpha.5.tgz", - "integrity": "sha512-DWkw6Kv83QC7BxPHqhNk0dso9ILNgH/J5Mz+xvx7j4jqjCIg33xRmU9rkdmdlC6K1uduPVF5cb50LpA1TBLr6A==", + "version": "3.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-3.0.0-alpha.6.tgz", + "integrity": "sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ==", "license": "MIT", "dependencies": { "lil-gui": "^0.20.0", diff --git a/package.json b/package.json index f284393..1e6dd20 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "main": "src/index.tsx", "dependencies": { - "gcode-preview": "3.0.0-alpha.5", + "gcode-preview": "3.0.0-alpha.6", "react": "18.0.0", "react-dom": "18.0.0", "react-scripts": "5.0.1",