├── fake.js ├── gcode-viewer.png ├── src ├── index.ts ├── LinePoint.ts ├── SegmentColorizer.ts ├── LineTube.test.ts ├── parser.test.ts ├── gcode.ts ├── LineTubeGeometry.ts └── parser.ts ├── jest.config.js ├── .gitignore ├── .yarnrc.yml ├── tsconfig.json ├── webpack.config.js ├── LICENSE ├── package.json └── README.md /fake.js: -------------------------------------------------------------------------------- 1 | // Just a fake file to replace some modules in tests 2 | -------------------------------------------------------------------------------- /gcode-viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aligator/gcode-viewer/HEAD/gcode-viewer.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./gcode"; 2 | export * from "./SegmentColorizer"; 3 | export { Color, Vector2 } from "three"; 4 | export * as THREE from "three"; 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | moduleNameMapper: { 5 | Lut: "/fake.js", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.log 4 | _bundles 5 | dist-esm 6 | 7 | .pnp.* 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/sdks 13 | !.yarn/versions -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.5.0.cjs 8 | -------------------------------------------------------------------------------- /src/LinePoint.ts: -------------------------------------------------------------------------------- 1 | import { Color, Vector3 } from "three"; 2 | 3 | export class LinePoint { 4 | public readonly point: Vector3; 5 | public readonly radius: number; 6 | public readonly color: Color; 7 | 8 | constructor( 9 | point: Vector3, 10 | radius: number, 11 | color: Color = new Color("#29BEB0"), 12 | ) { 13 | this.point = point; 14 | this.radius = radius; 15 | this.color = color; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": ["es2017", "es7", "es6", "dom"], 7 | "declaration": true, 8 | "outDir": "dist", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | "dist", 16 | "dist-esm", 17 | "_bundles", 18 | "./src/**/*.test.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); 3 | 4 | const mode = process.env.NODE_ENV || "development"; 5 | 6 | console.log("building for ", mode); 7 | 8 | module.exports = { 9 | mode, 10 | entry: { 11 | "gcode-viewer": "./src/index.ts", 12 | "gcode-viewer.min": "./src/index.ts", 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, "_bundles"), 16 | filename: "[name].js", 17 | libraryTarget: "umd", 18 | library: "gcodeViewer", 19 | umdNamedDefine: true, 20 | }, 21 | resolve: { 22 | alias: { 23 | three: path.resolve("./node_modules/three"), 24 | }, 25 | extensions: [".ts", ".js"], 26 | }, 27 | devtool: "source-map", 28 | optimization: { 29 | minimizer: [ 30 | new UglifyJsPlugin({ 31 | sourceMap: true, 32 | include: /\.min\.js$/, 33 | }), 34 | ], 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.tsx?$/, 40 | loader: "ts-loader", 41 | }, 42 | ], 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Johannes Hörmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gcode-viewer", 3 | "version": "0.7.1", 4 | "description": "A simple gcode viewer lib using three.js", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": "https://github.com/aligator/gcode-viewer", 8 | "author": "aligator ", 9 | "license": "MIT", 10 | "files": [ 11 | "dist/**/*", 12 | "_bundles/**/*", 13 | "dist-esm/**/*" 14 | ], 15 | "scripts": { 16 | "clean": "rm -rf _bundles dist dist-esm", 17 | "build": "NODE_ENV=production && yarn clean && tsc && tsc -m es6 --outDir dist-esm && webpack", 18 | "build:dev": "yarn clean && tsc && tsc -m es6 --outDir dist-esm && webpack", 19 | "prepublish": "yarn format && yarn build && yarn test", 20 | "postpublish": "sed -i \"s|https://unpkg.com/gcode-viewer@[0-9]*\\.[0-9]*\\.[0-9]*|https://unpkg.com/gcode-viewer@$(jq -r .version package.json)|g\" example/*.html && git add example/*.html", 21 | "release": "release-it", 22 | "test": "jest", 23 | "format": "prettier -w \"**/*.{js,ts,css,scss,md}\"" 24 | }, 25 | "dependencies": { 26 | "three": "^0.151.3" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^29.5.0", 30 | "@types/three": "^0.150.1", 31 | "jest": "^29.5.0", 32 | "jest-environment-jsdom": "^29.5.0", 33 | "prettier": "^3.4.1", 34 | "release-it": "^17.10.0", 35 | "ts-jest": "^29.1.0", 36 | "ts-loader": "^9.4.2", 37 | "typescript": "^5.0.4", 38 | "uglifyjs-webpack-plugin": "^2.2.0", 39 | "webpack": "^5.79.0", 40 | "webpack-cli": "^5.0.1" 41 | }, 42 | "packageManager": "yarn@3.5.0" 43 | } 44 | -------------------------------------------------------------------------------- /src/SegmentColorizer.ts: -------------------------------------------------------------------------------- 1 | import { Color, Vector3 } from "three"; 2 | import { Lut } from "three/examples/jsm/math/Lut"; 3 | 4 | export interface SegmentMetadata { 5 | segmentStart: Vector3; 6 | segmentEnd: Vector3; 7 | radius: number; 8 | temp: number; 9 | speed: number; 10 | 11 | gCodeLine: number; 12 | } 13 | 14 | const DEFAULT_COLOR = new Color("#29BEB0"); 15 | 16 | export interface SegmentColorizer { 17 | getColor(meta: SegmentMetadata): Color; 18 | } 19 | 20 | export interface LineColorizerOptions { 21 | defaultColor: Color; 22 | } 23 | 24 | export type LineColorConfig = { toLine: number; color: Color }[]; 25 | 26 | export class LineColorizer { 27 | // This assumes that getColor is called ordered by gCodeLine. 28 | private currentConfigIndex: number = 0; 29 | 30 | constructor( 31 | private readonly lineColorConfig: LineColorConfig, 32 | private readonly options?: LineColorizerOptions, 33 | ) {} 34 | 35 | getColor(meta: SegmentMetadata): Color { 36 | // Safeguard check if the config is too short. 37 | if (this.lineColorConfig[this.currentConfigIndex] === undefined) { 38 | return this.options?.defaultColor || DEFAULT_COLOR; 39 | } 40 | 41 | if (this.lineColorConfig[this.currentConfigIndex].toLine < meta.gCodeLine) { 42 | this.currentConfigIndex++; 43 | } 44 | 45 | return ( 46 | this.lineColorConfig[this.currentConfigIndex].color || 47 | this.options?.defaultColor || 48 | DEFAULT_COLOR 49 | ); 50 | } 51 | } 52 | 53 | export class SimpleColorizer implements SegmentColorizer { 54 | private readonly color; 55 | 56 | constructor(color = DEFAULT_COLOR) { 57 | this.color = color; 58 | } 59 | 60 | getColor(): Color { 61 | return this.color; 62 | } 63 | } 64 | 65 | export abstract class LutColorizer implements SegmentColorizer { 66 | protected readonly lut: Lut; 67 | 68 | constructor(lut = new Lut("cooltowarm")) { 69 | this.lut = lut; 70 | } 71 | 72 | abstract getColor(meta: SegmentMetadata): Color; 73 | } 74 | 75 | export class SpeedColorizer extends LutColorizer { 76 | constructor(minSpeed: number, maxSpeed: number) { 77 | super(); 78 | this.lut.setMin(minSpeed); 79 | this.lut.setMax(maxSpeed); 80 | } 81 | 82 | getColor(meta: SegmentMetadata): Color { 83 | return this.lut.getColor(meta.speed); 84 | } 85 | } 86 | 87 | export class TempColorizer extends LutColorizer { 88 | constructor(minTemp: number, maxTemp: number) { 89 | super(); 90 | this.lut.setMin(minTemp); 91 | this.lut.setMax(maxTemp); 92 | } 93 | 94 | getColor(meta: SegmentMetadata): Color { 95 | return this.lut.getColor(meta.temp); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/LineTube.test.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "three"; 2 | import { LinePoint } from "./LinePoint"; 3 | import { LineTubeGeometry } from "./LineTubeGeometry"; 4 | 5 | describe("LineTube", () => { 6 | describe("slice", () => { 7 | const radialSegments = 5; 8 | 9 | /** 10 | * Helper function to calculate the amount of indices 11 | */ 12 | function countExpected( 13 | radialSegments: number, 14 | segmentCount: number, 15 | withStart: boolean, 16 | withEnd: boolean, 17 | ) { 18 | let res = (radialSegments + 1) * 6 * 2 * segmentCount; 19 | if (withStart) { 20 | res += radialSegments * 6; 21 | } 22 | 23 | if (!withEnd) { 24 | res -= radialSegments * 6; 25 | } 26 | return res; 27 | } 28 | 29 | describe.each<{ 30 | name: string; 31 | points: LinePoint[]; 32 | sliceStart?: number; 33 | sliceEnd?: number; 34 | expectedCount?: number; 35 | expectedError?: string; 36 | }>([ 37 | { 38 | name: "middle - one segment", 39 | points: [ 40 | new LinePoint(new Vector3(0, 0, 0), 1), 41 | new LinePoint(new Vector3(1, 0, 0), 1), 42 | new LinePoint(new Vector3(1, 1, 0), 1), 43 | new LinePoint(new Vector3(0, 1, 0), 1), 44 | new LinePoint(new Vector3(0, 1, 1), 1), 45 | ], 46 | expectedCount: countExpected(radialSegments, 1, false, false), 47 | sliceStart: 1, 48 | sliceEnd: 3, 49 | }, 50 | { 51 | name: "no start / end", 52 | points: [ 53 | new LinePoint(new Vector3(0, 0, 0), 1), 54 | new LinePoint(new Vector3(1, 0, 0), 1), 55 | new LinePoint(new Vector3(1, 1, 0), 1), 56 | new LinePoint(new Vector3(0, 1, 0), 1), 57 | new LinePoint(new Vector3(0, 1, 1), 1), 58 | ], 59 | expectedCount: countExpected(radialSegments, 4, true, true), 60 | }, 61 | { 62 | name: "no end", 63 | points: [ 64 | new LinePoint(new Vector3(0, 0, 0), 1), 65 | new LinePoint(new Vector3(1, 0, 0), 1), 66 | new LinePoint(new Vector3(1, 1, 0), 1), 67 | new LinePoint(new Vector3(0, 1, 0), 1), 68 | new LinePoint(new Vector3(0, 1, 1), 1), 69 | ], 70 | expectedCount: countExpected(radialSegments, 2, false, true), 71 | sliceStart: 2, 72 | }, 73 | { 74 | name: "no start", 75 | points: [ 76 | new LinePoint(new Vector3(0, 0, 0), 1), 77 | new LinePoint(new Vector3(1, 0, 0), 1), 78 | new LinePoint(new Vector3(1, 1, 0), 1), 79 | new LinePoint(new Vector3(0, 1, 0), 1), 80 | new LinePoint(new Vector3(0, 1, 1), 1), 81 | ], 82 | expectedCount: countExpected(radialSegments, 2, false, true), 83 | sliceEnd: 3, 84 | }, 85 | { 86 | name: "negative start", 87 | points: [ 88 | new LinePoint(new Vector3(0, 0, 0), 1), 89 | new LinePoint(new Vector3(1, 0, 0), 1), 90 | new LinePoint(new Vector3(1, 1, 0), 1), 91 | new LinePoint(new Vector3(0, 1, 0), 1), 92 | new LinePoint(new Vector3(0, 1, 1), 1), 93 | ], 94 | expectedError: "negative values are not supported, yet", 95 | sliceStart: -1, 96 | }, 97 | { 98 | name: "negative end", 99 | points: [ 100 | new LinePoint(new Vector3(0, 0, 0), 1), 101 | new LinePoint(new Vector3(1, 0, 0), 1), 102 | new LinePoint(new Vector3(1, 1, 0), 1), 103 | new LinePoint(new Vector3(0, 1, 0), 1), 104 | new LinePoint(new Vector3(0, 1, 1), 1), 105 | ], 106 | expectedError: "negative values are not supported, yet", 107 | sliceEnd: -1, 108 | }, 109 | { 110 | name: "smaller end than start", 111 | points: [ 112 | new LinePoint(new Vector3(0, 0, 0), 1), 113 | new LinePoint(new Vector3(1, 0, 0), 1), 114 | new LinePoint(new Vector3(1, 1, 0), 1), 115 | new LinePoint(new Vector3(0, 1, 0), 1), 116 | new LinePoint(new Vector3(0, 1, 1), 1), 117 | ], 118 | expectedCount: 0, 119 | sliceStart: 3, 120 | sliceEnd: 2, 121 | }, 122 | { 123 | name: "min & max as start & end", 124 | points: [ 125 | new LinePoint(new Vector3(0, 0, 0), 1), 126 | new LinePoint(new Vector3(1, 0, 0), 1), 127 | new LinePoint(new Vector3(1, 1, 0), 1), 128 | new LinePoint(new Vector3(0, 1, 0), 1), 129 | new LinePoint(new Vector3(0, 1, 1), 1), 130 | ], 131 | expectedCount: countExpected(radialSegments, 4, true, true), 132 | sliceStart: 0, 133 | sliceEnd: 5, 134 | }, 135 | { 136 | name: "only one line", 137 | points: [ 138 | new LinePoint(new Vector3(0, 0, 0), 1), 139 | new LinePoint(new Vector3(1, 0, 0), 1), 140 | ], 141 | expectedCount: countExpected(radialSegments, 1, true, true), 142 | sliceStart: 0, 143 | sliceEnd: 2, 144 | }, 145 | ])("with values", (t) => { 146 | test(t.name, () => { 147 | const lt = new LineTubeGeometry(5); 148 | 149 | t.points.forEach((p) => { 150 | lt.add(p); 151 | }); 152 | lt.finish(); 153 | 154 | const errorTest = () => { 155 | lt.slice(t.sliceStart, t.sliceEnd); 156 | }; 157 | 158 | if (t.expectedError) { 159 | expect(errorTest).toThrowError(t.expectedError); 160 | return; 161 | } else { 162 | expect(errorTest).not.toThrowError(); 163 | } 164 | 165 | expect(lt.getIndex()?.count).toBe(t.expectedCount); 166 | }); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gcode-viewer 2 | 3 | [on npmjs.com](https://www.npmjs.com/package/gcode-viewer) 4 | 5 | This is a basic GCode viewer lib for js / ts. 6 | It is specifically built for [GoSlice](https://github.com/aligator/GoSlice) but may also work with GCode from other slicers. 7 | In contrast to other GCode viewers this one renders the lines using a mesh instead of lines. This is done because 8 | several browser-OS combination do not support line thickness rendering other than '1'. 9 | 10 | ![gcode-viewer](gcode-viewer.png) 11 | 12 | ## Known Problems - Guarantees 13 | - As long as it's pre v1.0.0 I give no guarantees, however I still try to avoid breaking changes. 14 | - As this lib is built specifically for my own experimental [GoSlice](https://github.com/aligator/GoSlice), it does not support all features. However if nothing special is needed, it should work - No guarantees. 15 | - The lib currently "embeds" threejs. 16 | You can access the embedded threejs by using `gcodeViewer.THREE` 17 | It would be much better if it would use threejs as dependency as embedding may introduce incompatibilities when a separate threejs is used. 18 | 19 | ## Features 20 | 21 | - slicing the viewed lines either by layer or line by line 22 | - line thickness based on the extrusion amount 23 | - colorize the lines based on line-metadata such as temperature, speed or gcode line 24 | - changeable amount of radial segments per line - less (e.g. 3) is faster and needs less RAM, more (e.g. 8 -> the default) may look better. 25 | - uses orbit controls from three js 26 | - relative movement for xyz and extrusion 27 | 28 | ## Contribution 29 | 30 | You are welcome to help. 31 | [Just look for open issues](https://github.com/aligator/gcode-viewer/issues) and pick one, create new issues or create new pull requests. 32 | 33 | ## Usage 34 | 35 | ### Examples 36 | 37 | [Take a look at this example.](example/index.html) 38 | [Or the extended example with some controls](example/extended.html) 39 | [Also take a look here.](https://github.com/aligator/dev/blob/main/src/windows/gCodeViewer.tsx) 40 | 41 | ### Used by others 42 | 43 | [3D Printer callibration tool using the gcode-viewer](https://ellis3dp.com/Pressure_Linear_Advance_Tool/) 44 | 45 | ### Setup 46 | 47 | ```js 48 | import { GCodeRenderer, Color, SpeedColorizer } from "gcode-viewer"; 49 | 50 | const renderer = new GCodeRenderer(gcodeString, 800, 600, new Color(0x808080)); 51 | 52 | // This is an example using the Speed colorizer. 53 | // Other options are: 54 | // * SimpleColorizer (default) - sets all lines to the same color 55 | // * SpeedColorizer - colorizes based on the speed / feed rate 56 | // * TempColorizer - colorizes based on the temperature 57 | // * LineColorizer - colorizes based on the gcodeLine 58 | renderer.colorizer = new SpeedColorizer( 59 | this.renderer.getMinMaxValues().minSpeed || 0, 60 | this.renderer.getMinMaxValues().maxSpeed, 61 | ); 62 | 63 | document.getElementById("gcode-viewer").append(renderer.element()); 64 | 65 | renderer.render().then(() => console.log("rendering finished")); 66 | ``` 67 | 68 | ### Resize 69 | 70 | Just call `renderer.resize(width, height)` whenever the size changes. 71 | 72 | ### Slice the rendered model 73 | 74 | To only show specific parts of the model you can use this: 75 | 76 | - `renderer.sliceLayer(minLayer, maxLayer)` to slice based on the layer 77 | - `renderer.slice(minPointNr, maxPointNr)` to slice based on the amount of points 78 | 79 | For both see the documentation in the code/comments. 80 | 81 | To get information about the start and end of specific layers, you can use: 82 | `renderer.getLayerDefinition(layerIndex)` 83 | 84 | Or to get all layer definitions at once: 85 | `renderer.getLayerDefinitions()` 86 | `renderer.getLayerDefinitionsNoCopy()` (use only if you know what you are doing) 87 | 88 | Note: The layer definitions are only available after the first render. 89 | 90 | ### Line resolution 91 | 92 | To save some Memory and speedup the rendering a bit, you can reduce 93 | the amount of planes per segment used: 94 | 95 | ```js 96 | renderer.radialSegments = 3; 97 | ``` 98 | 99 | The default is `8`. 100 | 101 | ### Travel lines 102 | 103 | You can change the line width of travel lines: 104 | 105 | ```js 106 | renderer.travelWidth = 0.1; 107 | ``` 108 | 109 | The default is `0.01`. `0` is also possible to completely hide them. 110 | 111 | ### Gcode LayerViews 112 | 113 | The default for sectioning the layers is determining the layers based on changing z-values on the gcode. 114 | However, If the user wants layers to be taken from the comments of the Gcode such as `;LAYER:1`. they can specify the lineType as: 115 | 116 | ```js 117 | renderer.layerType = LayerType.LAYER_COMMENTS; 118 | ``` 119 | 120 | ### Configure nozzle offsets for dual extrusion 121 | 122 | If you have a dual extruder printer the gcode paths for the second extruder may be offset. To correctly render the paths you can set the nozzle offsets: 123 | 124 | ```js 125 | renderer.nozzleOffsets = [ 126 | new gcodeViewer.Vector2(0, 0), 127 | new gcodeViewer.Vector2(10, 10), 128 | ] 129 | ``` 130 | 131 | ### Access three.js 132 | 133 | Both, the scene and the whole three.js is exported, so you can use it. 134 | For example you can customize the scene setup: 135 | 136 | ```js 137 | renderer.setupScene = () => { 138 | // Set up some lights. (use different lights in this example) 139 | const ambientLight = new gcodeViewer.THREE.AmbientLight(0xff0000, 0.5); 140 | renderer.scene.add(ambientLight); 141 | 142 | const spotLight = new gcodeViewer.THREE.SpotLight(0x00ff00, 0.9); 143 | spotLight.position.set(200, 400, 300); 144 | spotLight.lookAt(new gcodeViewer.THREE.Vector3(0, 0, 0)); 145 | 146 | const spotLight2 = new gcodeViewer.THREE.SpotLight(0x0000ff, 0.9); 147 | spotLight2.position.set(-200, -400, -300); 148 | spotLight2.lookAt(new gcodeViewer.THREE.Vector3(0, 0, 0)); 149 | renderer.scene.add(spotLight); 150 | renderer.scene.add(spotLight2); 151 | 152 | renderer.fitCamera(); 153 | }; 154 | renderer.render().then(() => console.log("rendering finished")); 155 | ``` 156 | -------------------------------------------------------------------------------- /src/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { GCodeParser } from "./parser"; 2 | 3 | describe("GCodeParser", () => { 4 | describe("slice", () => { 5 | const gCode14Points = ` 6 | G1 Z5 F5000 ; lift nozzle 7 | G0 X111.78 Y83.52 Z0.20 F9000 8 | G1 X112.30 Y83.53 F1800 E0.0173 9 | G1 X112.81 Y83.59 E0.0343 10 | 11 | G1 X113.59 Y83.76 E0.0607 12 | G1 X114.18 Y83.96 E0.0814 13 | G1 X115.08 Y84.39 E0.1148 14 | G1 X116.08 Y84.39 E0.1148 15 | 16 | G1 X113.59 Y83.76 E0.0607 17 | G1 X114.18 Y83.96 E0.0814 18 | G1 X115.08 Y84.39 E0.1148 19 | G1 X116.08 Y84.39 E0.1148 20 | 21 | G1 X115.08 Y86.39 E0.1148 22 | G1 X117.08 Y84.39 E0.1148 23 | `; 24 | const radialSegments = 5; 25 | 26 | /** 27 | * Helper function to calculate the amount of indices 28 | */ 29 | function countExpected( 30 | radialSegments: number, 31 | segmentCount: number, 32 | withStart: boolean, 33 | withEnd: boolean, 34 | ) { 35 | let res = (radialSegments + 1) * 6 * 2 * segmentCount; 36 | if (withStart) { 37 | res += radialSegments * 6; 38 | } 39 | 40 | if (!withEnd) { 41 | res -= radialSegments * 6; 42 | } 43 | return res; 44 | } 45 | 46 | describe.each<{ 47 | name: string; 48 | gcode: string; 49 | pointsPerObject: number; 50 | sliceStart?: number; 51 | sliceEnd?: number; 52 | expectedCount?: number[]; 53 | expectedError?: string; 54 | }>([ 55 | { 56 | name: "no start / end", 57 | gcode: gCode14Points, 58 | pointsPerObject: 4, 59 | expectedCount: [ 60 | // Note that the first one contains 4 points and the following ones the last point from the previous + the next (up to) 4 points 61 | countExpected(radialSegments, 3, true, true), // line from point 1 - 2 - 3 - 4 62 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 63 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 64 | countExpected(radialSegments, 2, true, true), // line from point 12- 13- 14 65 | ], 66 | }, 67 | { 68 | name: "partial first object", 69 | gcode: gCode14Points, 70 | pointsPerObject: 4, 71 | expectedCount: [ 72 | countExpected(radialSegments, 1, false, true), // line from point 3 - 4 73 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 74 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 75 | countExpected(radialSegments, 2, true, true), // line from point 12- 13- 14 76 | ], 77 | sliceStart: 2, 78 | }, 79 | { 80 | name: "no first object", 81 | gcode: gCode14Points, 82 | pointsPerObject: 4, 83 | expectedCount: [ 84 | countExpected(radialSegments, 0, false, true), // line from point 85 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 86 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 87 | countExpected(radialSegments, 2, true, true), // line from point 12- 13- 14 88 | ], 89 | sliceStart: 3, 90 | }, 91 | { 92 | name: "partial second object", 93 | gcode: gCode14Points, 94 | pointsPerObject: 4, 95 | expectedCount: [ 96 | countExpected(radialSegments, 0, false, true), // line from point 97 | countExpected(radialSegments, 3, false, true), // line from point 5 - 6 - 7 - 8 98 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 99 | countExpected(radialSegments, 2, true, true), // line from point 12- 13- 14 100 | ], 101 | sliceStart: 4, 102 | }, 103 | { 104 | name: "partial last object", 105 | gcode: gCode14Points, 106 | pointsPerObject: 4, 107 | expectedCount: [ 108 | countExpected(radialSegments, 3, true, true), // line from point 1 - 2 - 3 - 4 109 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 110 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 111 | countExpected(radialSegments, 1, true, false), // line from point 12- 13 112 | ], 113 | sliceEnd: 13, 114 | }, 115 | { 116 | name: "no last object", 117 | gcode: gCode14Points, 118 | pointsPerObject: 4, 119 | expectedCount: [ 120 | countExpected(radialSegments, 3, true, true), // line from point 1 - 2 - 3 - 4 121 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 122 | countExpected(radialSegments, 4, true, true), // line from point 8 - 9 - 10- 11- 12 123 | countExpected(radialSegments, 0, true, false), // line from point 124 | ], 125 | sliceEnd: 12, 126 | }, 127 | { 128 | name: "partial second last object", 129 | gcode: gCode14Points, 130 | pointsPerObject: 4, 131 | expectedCount: [ 132 | countExpected(radialSegments, 3, true, true), // line from point 1 - 2 - 3 - 4 133 | countExpected(radialSegments, 4, true, true), // line from point 4 - 5 - 6 - 7 - 8 134 | countExpected(radialSegments, 3, true, false), // line from point 8 - 9 - 10- 11 135 | countExpected(radialSegments, 0, true, false), // line from point 136 | ], 137 | sliceEnd: 11, 138 | }, 139 | { 140 | name: "partial start and end objects", 141 | gcode: gCode14Points, 142 | pointsPerObject: 4, 143 | expectedCount: [ 144 | countExpected(radialSegments, 0, false, true), // line from point 145 | countExpected(radialSegments, 2, false, true), // line from point 6 - 7 - 8 146 | countExpected(radialSegments, 2, true, false), // line from point 8 - 9 - 10 147 | countExpected(radialSegments, 0, true, false), // line from point 148 | ], 149 | sliceStart: 5, 150 | sliceEnd: 10, 151 | }, 152 | { 153 | name: "two segments over gap", 154 | gcode: gCode14Points, 155 | pointsPerObject: 4, 156 | expectedCount: [ 157 | countExpected(radialSegments, 0, false, true), // line from point 158 | countExpected(radialSegments, 1, false, true), // line from point 7 - 8 159 | countExpected(radialSegments, 1, true, false), // line from point 8 - 9 160 | countExpected(radialSegments, 0, true, false), // line from point 161 | ], 162 | sliceStart: 6, 163 | sliceEnd: 9, 164 | }, 165 | { 166 | name: "one segment", 167 | gcode: gCode14Points, 168 | pointsPerObject: 4, 169 | expectedCount: [ 170 | countExpected(radialSegments, 0, false, true), // line from point 171 | countExpected(radialSegments, 1, false, false), // line from point 6 - 7 172 | countExpected(radialSegments, 0, true, false), // line from point 173 | countExpected(radialSegments, 0, true, false), // line from point 174 | ], 175 | sliceStart: 5, 176 | sliceEnd: 7, 177 | }, 178 | { 179 | name: "one segment at end of one object", 180 | gcode: gCode14Points, 181 | pointsPerObject: 4, 182 | expectedCount: [ 183 | countExpected(radialSegments, 0, false, true), // line from point 184 | countExpected(radialSegments, 1, false, true), // line from point 7 - 8 185 | countExpected(radialSegments, 0, true, false), // line from point 186 | countExpected(radialSegments, 0, true, false), // line from point 187 | ], 188 | sliceStart: 6, 189 | sliceEnd: 8, 190 | }, 191 | { 192 | name: "one segment at start of one object", 193 | gcode: gCode14Points, 194 | pointsPerObject: 4, 195 | expectedCount: [ 196 | countExpected(radialSegments, 0, false, true), // line from point 197 | countExpected(radialSegments, 0, false, true), // line from point 198 | countExpected(radialSegments, 1, true, false), // line from point 8 - 9 199 | countExpected(radialSegments, 0, true, false), // line from point 200 | ], 201 | sliceStart: 7, 202 | sliceEnd: 9, 203 | }, 204 | { 205 | name: "smaller end than start", 206 | gcode: gCode14Points, 207 | pointsPerObject: 4, 208 | expectedCount: [0, 0, 0, 0], 209 | sliceStart: 9, 210 | sliceEnd: 6, 211 | }, 212 | { 213 | name: "negative start", 214 | gcode: gCode14Points, 215 | pointsPerObject: 4, 216 | expectedError: "negative values are not supported, yet", 217 | sliceStart: -1, 218 | }, 219 | { 220 | name: "negative end", 221 | gcode: gCode14Points, 222 | pointsPerObject: 4, 223 | expectedError: "negative values are not supported, yet", 224 | sliceEnd: -1, 225 | }, 226 | { 227 | name: "only one line", 228 | gcode: "G1 Z5 F5000\nG0 X111.78 Y83.52 Z0.20 F9000", 229 | expectedCount: [countExpected(radialSegments, 1, true, true)], 230 | pointsPerObject: 4, 231 | }, 232 | ])("with values", (t) => { 233 | test(t.name, async () => { 234 | const lt = new GCodeParser(t.gcode); 235 | lt.radialSegments = radialSegments; 236 | lt.pointsPerObject = t.pointsPerObject; 237 | await lt.parse(); 238 | 239 | const errorTest = () => { 240 | lt.slice(t.sliceStart, t.sliceEnd); 241 | }; 242 | 243 | if (t.expectedError) { 244 | expect(errorTest).toThrowError(t.expectedError); 245 | return; 246 | } else { 247 | expect(errorTest).not.toThrowError(); 248 | } 249 | 250 | if (t.expectedCount === undefined) { 251 | return; 252 | } 253 | 254 | expect(lt.getGeometries().length).toBe(t.expectedCount.length); 255 | 256 | lt.getGeometries().forEach((g, i) => { 257 | if (t.expectedCount === undefined) { 258 | return; 259 | } 260 | expect(g.getIndex()?.count).toBe(t.expectedCount[i]); 261 | }); 262 | }); 263 | }); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /src/gcode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Box3, 3 | Mesh, 4 | PerspectiveCamera, 5 | Color, 6 | Scene, 7 | Vector3, 8 | WebGLRenderer, 9 | AmbientLight, 10 | SpotLight, 11 | MeshPhongMaterial, 12 | Vector2, 13 | } from "three"; 14 | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; 15 | import { GCodeParser, LayerDefinition, LayerType } from "./parser"; 16 | import { SegmentColorizer } from "./SegmentColorizer"; 17 | 18 | /** 19 | * GCode renderer which parses a GCode file and displays it using 20 | * three.js. Use .element() to retrieve the DOM canvas element. 21 | */ 22 | export class GCodeRenderer { 23 | public readonly scene: Scene; 24 | private readonly renderer: WebGLRenderer; 25 | private cameraControl?: OrbitControls; 26 | 27 | private camera: PerspectiveCamera; 28 | 29 | private lineMaterial = new MeshPhongMaterial({ vertexColors: true }); 30 | 31 | private readonly parser: GCodeParser; 32 | 33 | // Public configurations: 34 | 35 | /** 36 | * Here you can replace the default scene setup (called after adding the model). 37 | * You can use renderer.scene to get access to it and do whatever you want with it. 38 | * The default implementation just adds some lights and then calls renderer.fitCamera(). 39 | */ 40 | public setupScene: () => void = () => { 41 | // Set up some lights. 42 | const ambientLight = new AmbientLight(0xffffff, 0.5); 43 | this.scene.add(ambientLight); 44 | 45 | const spotLight = new SpotLight(0xffffff, 0.9); 46 | spotLight.position.set(200, 400, 300); 47 | spotLight.lookAt(new Vector3(0, 0, 0)); 48 | 49 | const spotLight2 = new SpotLight(0xffffff, 0.9); 50 | spotLight2.position.set(-200, -400, -300); 51 | spotLight2.lookAt(new Vector3(0, 0, 0)); 52 | this.scene.add(spotLight); 53 | this.scene.add(spotLight2); 54 | 55 | this.fitCamera(); 56 | }; 57 | 58 | /** 59 | * Width of travel-lines. Use 0 to hide them. 60 | * 61 | * @type number 62 | */ 63 | public get travelWidth(): number { 64 | return this.parser.travelWidth; 65 | } 66 | /** 67 | * Width of travel-lines. Use 0 to hide them. 68 | * 69 | * @type number 70 | */ 71 | public set travelWidth(w: number) { 72 | this.parser.travelWidth = w; 73 | } 74 | 75 | 76 | /** 77 | * Nozzle offsets for multi-extrusion. 78 | * 79 | * @type Vector2[] 80 | */ 81 | public get nozzleOffsets(): Vector2[] { 82 | return this.parser.nozzleOffsets; 83 | } 84 | 85 | /** 86 | * Nozzle offsets for multi-extrusion. 87 | * 88 | * @type Vector2[] 89 | */ 90 | public set nozzleOffsets(nozzleOffsets: Vector2[]) { 91 | this.parser.nozzleOffsets = nozzleOffsets; 92 | } 93 | 94 | /** 95 | * Type to determine how the layer change is detected. 96 | */ 97 | public get layerType(): LayerType { 98 | return this.parser.layerType; 99 | } 100 | 101 | /** 102 | * Type to determine how the layer change is detected. 103 | * @param type : LayerType 104 | */ 105 | public set layerType(type: LayerType) { 106 | this.parser.layerType = type; 107 | } 108 | 109 | /** 110 | * Set any colorizer implementation to change the segment color based on the segment 111 | * metadata. Some default implementations are provided. 112 | * 113 | * @type SegmentColorizer 114 | */ 115 | public get colorizer(): SegmentColorizer { 116 | return this.parser.colorizer; 117 | } 118 | 119 | /** 120 | * Set any colorizer implementation to change the segment color based on the segment 121 | * metadata. Some default implementations are provided. 122 | * 123 | * @type SegmentColorizer 124 | */ 125 | public set colorizer(colorizer: SegmentColorizer) { 126 | this.parser.colorizer = colorizer; 127 | } 128 | 129 | /** 130 | * The number of radial segments per line. 131 | * Less (e.g. 3) provides faster rendering with less memory usage. 132 | * More (e.g. 8) provides a better look. 133 | * 134 | * @default 8 135 | * @type number 136 | */ 137 | public get radialSegments(): number { 138 | return this.parser.radialSegments; 139 | } 140 | 141 | /** 142 | * The number of radial segments per line. 143 | * Less (e.g. 3) provides faster rendering with less memory usage. 144 | * More (e.g. 8) provides a better look. 145 | * 146 | * @default 8 147 | * @type number 148 | */ 149 | public set radialSegments(segments: number) { 150 | this.parser.radialSegments = segments; 151 | } 152 | 153 | /** 154 | * Internally the rendered object is split into several. This allows to reduce the 155 | * memory consumption while rendering. 156 | * You can set the number of points per object. 157 | * In most cases you can leave this at the default. 158 | * 159 | * @default 120000 160 | * @type number 161 | */ 162 | public get pointsPerObject(): number { 163 | return this.parser.pointsPerObject; 164 | } 165 | 166 | /** 167 | * Internally the rendered object is split into several. This allows to reduce the 168 | * memory consumption while rendering. 169 | * You can set the number of points per object. 170 | * In most cases you can leave this at the default. 171 | * 172 | * @default 120000 173 | * @type number 174 | */ 175 | public set pointsPerObject(num: number) { 176 | this.parser.pointsPerObject = num; 177 | } 178 | 179 | /** 180 | * Creates a new GCode renderer for the given gcode. 181 | * It initializes the canvas to the given size and 182 | * uses the passed color as background. 183 | * 184 | * @param {string} gCode 185 | * @param {number} width 186 | * @param {number} height 187 | * @param {Color} background 188 | */ 189 | constructor(gCode: string, width: number, height: number, background: Color) { 190 | this.scene = new Scene(); 191 | this.renderer = new WebGLRenderer(); 192 | this.renderer.setSize(width, height); 193 | this.renderer.setClearColor(background, 1); 194 | this.camera = this.newCamera(width, height); 195 | 196 | this.parser = new GCodeParser(gCode); 197 | } 198 | 199 | /** 200 | * This can be used to retrieve some min / max values which may 201 | * be needed as param for a colorizer. 202 | * @returns {{ 203 | * minTemp: number | undefined, 204 | * maxTemp: number, 205 | * minSpeed: number | undefined, 206 | * maxSpeed: number 207 | * }} 208 | */ 209 | public getMinMaxValues() { 210 | return this.parser.getMinMaxValues(); 211 | } 212 | 213 | private newCamera(width: number, height: number) { 214 | const camera = new PerspectiveCamera(75, width / height, 0.1, 1000); 215 | camera.up.set(0, 0, 1); 216 | 217 | if (this.cameraControl) { 218 | this.cameraControl.dispose(); 219 | } 220 | this.cameraControl = new OrbitControls(camera, this.renderer.domElement); 221 | this.cameraControl.enablePan = true; 222 | this.cameraControl.enableZoom = true; 223 | this.cameraControl.minDistance = -Infinity; 224 | this.cameraControl.maxDistance = Infinity; 225 | 226 | this.cameraControl?.addEventListener("change", () => { 227 | requestAnimationFrame(() => this.draw()); 228 | }); 229 | 230 | return camera; 231 | } 232 | 233 | public fitCamera() { 234 | const boundingBox = new Box3(this.parser.min, this.parser.max); 235 | const center = new Vector3(); 236 | boundingBox.getCenter(center); 237 | this.camera.position.x = 238 | (this.parser.min?.x || 0) + 239 | ((this.parser.max?.x || 0) - (this.parser.min?.x || 0)) / 2; 240 | this.camera.position.z = this.parser.max?.z || 0; 241 | 242 | if (this.cameraControl) { 243 | this.cameraControl.target = center; 244 | } 245 | 246 | this.camera.lookAt(center); 247 | 248 | this.draw(); 249 | } 250 | 251 | /** 252 | * Reads the GCode and renders it to a mesh. 253 | */ 254 | public async render() { 255 | await this.parser.parse(); 256 | 257 | this.parser.getGeometries().forEach((g) => { 258 | this.scene.add(new Mesh(g, this.lineMaterial)); 259 | }); 260 | this.setupScene(); 261 | } 262 | 263 | private draw() { 264 | if (this.parser.getGeometries().length === 0 || this.camera === undefined) { 265 | return; 266 | } 267 | 268 | this.renderer.render(this.scene, this.camera); 269 | } 270 | 271 | /** 272 | * Slices the rendered model based on the passed start and end point numbers. 273 | * (0, pointsCount()) renders everything 274 | * 275 | * Note: Currently negative values are not allowed. 276 | * 277 | * @param {number} start the starting segment 278 | * @param {number} end the ending segment (excluding) 279 | */ 280 | public slice(start: number = 0, end: number = this.pointsCount()) { 281 | this.parser.slice(start, end); 282 | this.draw(); 283 | } 284 | 285 | /** 286 | * Slices the rendered model based on the passed start and end line numbers. 287 | * (0, layerCount()) renders everything 288 | * 289 | * Note: Currently negative values are not allowed. 290 | * 291 | * @param {number} start the starting layer 292 | * @param {number} end the ending layer (excluding) 293 | */ 294 | public sliceLayer(start?: number, end?: number) { 295 | this.parser.sliceLayer(start, end); 296 | this.draw(); 297 | } 298 | 299 | /** 300 | * Retrieve the dom html canvas element where 301 | * the GCode viewer draws to. 302 | * @returns {HTMLCanvasElement} 303 | */ 304 | public element(): HTMLCanvasElement { 305 | return this.renderer.domElement; 306 | } 307 | 308 | /** 309 | * disposes everything which is dispose able. 310 | * Call this always before destroying the instance."" 311 | */ 312 | public dispose() { 313 | this.cameraControl?.dispose(); 314 | this.parser.dispose(); 315 | } 316 | 317 | /** 318 | * Get the amount of points in the model. 319 | * 320 | * @returns {number} 321 | */ 322 | public pointsCount(): number { 323 | return this.parser.pointsCount(); 324 | } 325 | 326 | /** 327 | * Get the amount of layers in the model. 328 | * This is an approximation which may be incorrect if the 329 | * nozzle moves downwards mid print. 330 | * 331 | * @returns {number} 332 | */ 333 | public layerCount(): number { 334 | return this.parser.layerCount(); 335 | } 336 | 337 | /** 338 | * Get an array containing the start and end-point of each layer. 339 | */ 340 | public getLayerDefinitions(): LayerDefinition[] { 341 | return this.parser.layerDefinition.map((definition) => ({ ...definition })); 342 | } 343 | 344 | /** 345 | * Get an array containing the start and end-point of each layer. 346 | * Same as getLayerDefinitions, but faster, as no deep copy is created. 347 | * Prefer getLayerDefinition over this method. 348 | * 349 | * Should only be used if getLayerDefinition is to slow and you know what you are doing! 350 | * 351 | * IMPORTANT: The returned array or it's items must not be modified! It is used internally. 352 | */ 353 | public getLayerDefinitionsNoCopy(): LayerDefinition[] { 354 | return this.parser.layerDefinition; 355 | } 356 | 357 | /** 358 | * Get the definition of a specific layer. 359 | */ 360 | public getLayerDefinition(layer: number): LayerDefinition | undefined { 361 | if (this.parser.layerDefinition[layer] === undefined) { 362 | return undefined; 363 | } 364 | return { ...this.parser.layerDefinition[layer] }; 365 | } 366 | 367 | /** 368 | * This can be called when a resize of the canvas is needed. 369 | * 370 | * @param {number} width 371 | * @param {number} height 372 | */ 373 | public resize(width: number, height: number) { 374 | this.renderer.setSize(width, height); 375 | 376 | const rot = this.camera.rotation; 377 | const pos = this.camera.position; 378 | this.camera = this.newCamera(width, height); 379 | this.fitCamera(); 380 | 381 | this.camera.rotation.copy(rot); 382 | this.camera.position.copy(pos); 383 | 384 | this.draw(); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/LineTubeGeometry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BufferGeometry, 3 | Color, 4 | Float32BufferAttribute, 5 | MathUtils, 6 | Matrix4, 7 | Vector3, 8 | } from "three"; 9 | import { LinePoint } from "./LinePoint"; 10 | 11 | interface PointData { 12 | pointNr: number; 13 | radialNr: number; 14 | vertices: number[]; 15 | normals: number[]; 16 | colors: number[]; 17 | } 18 | 19 | /** 20 | * This Tube geometry is similar to the TubeGeometry from three.js but 21 | * it draws the tube exactly like the given lines. It does not re-calculate 22 | * the segments using a curve, instead each point is exactly where it should be. 23 | * Also it provides an easy way to colorize each segment. 24 | * 25 | * TODO: As I searched for something like this quite some time without success, this 26 | * would be a good part to extract into another lib... 27 | */ 28 | export class LineTubeGeometry extends BufferGeometry { 29 | /** 30 | * Saves up to 4 linePoints to generate the model. 31 | * The oldest one get's dropped after generating as it's not needed anymore. 32 | */ 33 | private pointsBuffer: LinePoint[] = []; 34 | private pointsLength: number; 35 | private readonly radialSegments: number; 36 | 37 | // buffer 38 | private vertices: number[] = []; 39 | private normals: number[] = []; 40 | private colors: number[] = []; 41 | private uvs: number[] = []; 42 | private indices: number[] = []; 43 | 44 | private segmentsRadialNumbers: number[] = []; 45 | 46 | constructor(radialSegments = 8) { 47 | super(); 48 | 49 | //@ts-ignore 50 | this.type = "LineTubeGeometry"; 51 | this.pointsLength = 0; 52 | this.radialSegments = radialSegments; 53 | } 54 | 55 | dispose() { 56 | super.dispose(); 57 | this.pointsBuffer = []; 58 | this.normals = []; 59 | this.vertices = []; 60 | this.colors = []; 61 | this.uvs = []; 62 | this.indices = []; 63 | this.segmentsRadialNumbers = []; 64 | } 65 | 66 | public add(point: LinePoint) { 67 | this.pointsLength++; 68 | this.pointsBuffer.push(point); 69 | if (this.pointsBuffer.length === 3) { 70 | // For the first time, use index 0 to have 'no' previous 71 | this.generateSegment(0); 72 | } else if (this.pointsBuffer.length >= 4) { 73 | this.generateSegment(1); 74 | } 75 | } 76 | 77 | public finish() { 78 | // If the there are only two points in total it has to 79 | // be handled separately as the add mehtod only starts 80 | // segment generation at min 3 points. 81 | if (this.pointsBuffer.length == 2) { 82 | this.generateSegment(0); 83 | } else { 84 | // In all other cases generate the last segment. 85 | this.generateSegment(1); 86 | } 87 | 88 | this.setAttribute("position", new Float32BufferAttribute(this.vertices, 3)); 89 | this.setAttribute("normal", new Float32BufferAttribute(this.normals, 3)); 90 | this.setAttribute("color", new Float32BufferAttribute(this.colors, 3)); 91 | 92 | this.generateUVs(); 93 | this.setAttribute("uv", new Float32BufferAttribute(this.uvs, 2)); 94 | 95 | // finally create faces 96 | this.generateIndices(); 97 | this.setIndex(this.indices); 98 | 99 | // not needed anymore 100 | this.segmentsRadialNumbers = []; 101 | 102 | // these are now in the attribute buffers - can be deleted 103 | this.normals = []; 104 | this.colors = []; 105 | this.uvs = []; 106 | 107 | // The vertices are needed to slice. For now they need to be kept. 108 | } 109 | 110 | public pointsCount(): number { 111 | return this.pointsLength; 112 | } 113 | 114 | /** 115 | * Slices the rendered part of the line based on the passed start and end segments. 116 | * 0, this.pointsLength renders everything 117 | * @param start the starting segment 118 | * @param end the ending segment (excluding) 119 | */ 120 | public slice(start: number = 0, end: number = this.pointsLength) { 121 | if (start === end) { 122 | this.setIndex([]); 123 | return; 124 | } 125 | 126 | // TODO: support negative values like the slice from Array? 127 | if (start < 0 || end < 0) { 128 | throw new Error("negative values are not supported, yet"); 129 | } 130 | 131 | const seg = (this.radialSegments + 1) * 6; 132 | 133 | let startI = start * seg * 2; 134 | let endI = (end - 1) * seg * 2; 135 | 136 | if (end === this.pointsLength) { 137 | // add the ending 138 | endI += this.radialSegments * 6; 139 | } 140 | 141 | if (start > 0) { 142 | // remove the starting 143 | startI += this.radialSegments * 6; 144 | } 145 | 146 | // TODO: render an 'ending / starting' so that there is no hole. 147 | this.setIndex(this.indices.slice(startI, endI)); 148 | } 149 | 150 | private generateSegment(i: number) { 151 | let prevPoint = this.pointsBuffer[i - 1]; 152 | 153 | // point and nextPoint should always exist... 154 | let point = this.pointsBuffer[i]; 155 | let nextPoint = this.pointsBuffer[i + 1]; 156 | let nextNextPoint = this.pointsBuffer[i + 2]; 157 | 158 | // ...except only one line exists in total 159 | if (nextPoint === undefined) { 160 | return; 161 | } 162 | 163 | const frame = this.computeFrenetFrames( 164 | [point.point, nextPoint.point], 165 | false, 166 | ); 167 | 168 | const lastRadius = this.pointsBuffer[i - 1]?.radius || 0; 169 | 170 | function createPointData( 171 | pointNr: number, 172 | radialNr: number, 173 | normal: Vector3, 174 | point: Vector3, 175 | radius: number, 176 | color: Color, 177 | ): PointData { 178 | return { 179 | pointNr, 180 | radialNr, 181 | normals: [normal.x, normal.y, normal.z], 182 | vertices: [ 183 | point.x + radius * normal.x, 184 | point.y + radius * normal.y, 185 | point.z + radius * normal.z, 186 | ], 187 | colors: color.toArray(), 188 | }; 189 | } 190 | 191 | // The data is saved here to maintain the correct order. 192 | // After the segments are generated, they are pushed to the buffer one by one. 193 | const segmentsPoints: PointData[][] = [[], [], [], []]; 194 | 195 | // generate normals and vertices for the current segment 196 | for (let j = 0; j <= this.radialSegments; j++) { 197 | const v = (j / this.radialSegments) * Math.PI * 2; 198 | 199 | const sin = Math.sin(v); 200 | const cos = -Math.cos(v); 201 | 202 | // vertex 203 | 204 | let normal = new Vector3(); 205 | normal.x = cos * frame.normals[0].x + sin * frame.binormals[0].x; 206 | normal.y = cos * frame.normals[0].y + sin * frame.binormals[0].y; 207 | normal.z = cos * frame.normals[0].z + sin * frame.binormals[0].z; 208 | normal.normalize(); 209 | 210 | // When the previous point doesn't exist, create one with the radius 0 (lastRadius is set to 0 in this case), 211 | // to create a closed starting point. 212 | if (prevPoint === undefined) { 213 | segmentsPoints[0].push( 214 | createPointData(i, j, normal, point.point, lastRadius, point.color), 215 | ); 216 | } 217 | 218 | // Then insert the current point with the current radius 219 | segmentsPoints[1].push( 220 | createPointData(i, j, normal, point.point, point.radius, point.color), 221 | ); 222 | 223 | // And also the next point with the current radius to finish the current line. 224 | segmentsPoints[2].push( 225 | createPointData( 226 | i, 227 | j, 228 | normal, 229 | nextPoint.point, 230 | point.radius, 231 | point.color, 232 | ), 233 | ); 234 | 235 | // if the next point is the last one, also finish the line by inserting one with zero radius. 236 | if (nextNextPoint === undefined) { 237 | segmentsPoints[3].push( 238 | createPointData(i + 1, j, normal, nextPoint.point, 0, point.color), 239 | ); 240 | } 241 | } 242 | 243 | // Save everything into the buffers. 244 | segmentsPoints.forEach((p) => { 245 | p.forEach((pp) => { 246 | pp.normals && this.normals.push(...pp.normals); 247 | pp.vertices && this.vertices.push(...pp.vertices); 248 | pp.colors && this.colors.push(...pp.colors); 249 | }); 250 | this.segmentsRadialNumbers.push(...p.map((cur) => cur.radialNr)); 251 | }); 252 | 253 | if (this.pointsBuffer.length >= 4) { 254 | // delete the first point. It's not needed anymore. 255 | this.pointsBuffer = this.pointsBuffer.slice(1); 256 | } 257 | } 258 | 259 | private generateIndices() { 260 | for ( 261 | let i = this.radialSegments + 2; 262 | i < this.segmentsRadialNumbers.length; 263 | i++ 264 | ) { 265 | const a = i - 1; 266 | const b = i - this.radialSegments - 2; 267 | const c = i - this.radialSegments - 1; 268 | const d = i; 269 | 270 | this.indices.push(a, b, c, d, a, c); 271 | } 272 | } 273 | 274 | private generateUVs() { 275 | for (let i = 0; i < this.segmentsRadialNumbers.length; i++) { 276 | this.uvs.push( 277 | i / this.radialSegments - 1, 278 | this.segmentsRadialNumbers[i] / this.radialSegments, 279 | ); 280 | } 281 | } 282 | 283 | toJSON(): string { 284 | throw new Error("not implemented"); 285 | } 286 | 287 | private getTangent( 288 | point1: Vector3, 289 | point2: Vector3, 290 | optionalTarget?: Vector3, 291 | ) { 292 | const tangent = optionalTarget || new Vector3(); 293 | tangent.copy(point1).sub(point2).normalize(); 294 | return tangent; 295 | } 296 | 297 | private computeFrenetFrames(points: Vector3[], closed: boolean) { 298 | // Slightly modified from the three.js curve. 299 | 300 | // see http://www.cs.indiana.edu/pub/techreports/TR425.pdf 301 | 302 | const normal = new Vector3(); 303 | const tangents = []; 304 | const normals = []; 305 | const binormals = []; 306 | 307 | const vec = new Vector3(); 308 | const mat = new Matrix4(); 309 | 310 | // compute the tangent vectors for each segment 311 | for ( 312 | let i = 1; 313 | i < Math.floor(points.length / 2) + (points.length % 2 ? 0 : 1); 314 | i++ 315 | ) { 316 | const t = this.getTangent(points[i - 1], points[i]); 317 | t.normalize(); 318 | tangents.push(t); 319 | } 320 | 321 | const segments = tangents.length - 1; 322 | 323 | // select an initial normal vector perpendicular to the first tangent vector, 324 | // and in the direction *2-1of the minimum tangent xyz component 325 | 326 | normals[0] = new Vector3(); 327 | binormals[0] = new Vector3(); 328 | let min = Number.MAX_VALUE; 329 | const tx = Math.abs(tangents[0].x); 330 | const ty = Math.abs(tangents[0].y); 331 | const tz = Math.abs(tangents[0].z); 332 | 333 | if (tx <= min) { 334 | min = tx; 335 | normal.set(1, 0, 0); 336 | } 337 | 338 | if (ty <= min) { 339 | min = ty; 340 | normal.set(0, 1, 0); 341 | } 342 | 343 | if (tz <= min) { 344 | normal.set(0, 0, 1); 345 | } 346 | 347 | vec.crossVectors(tangents[0], normal).normalize(); 348 | 349 | normals[0].crossVectors(tangents[0], vec); 350 | binormals[0].crossVectors(tangents[0], normals[0]); 351 | 352 | // compute the slowly-varying normal and binormal vectors for each segment on the curve 353 | 354 | for (let i = 1; i <= segments; i++) { 355 | normals[i] = normals[i - 1].clone(); 356 | 357 | binormals[i] = binormals[i - 1].clone(); 358 | 359 | vec.crossVectors(tangents[i - 1], tangents[i]); 360 | 361 | if (vec.length() > Number.EPSILON) { 362 | vec.normalize(); 363 | 364 | const theta = Math.acos( 365 | MathUtils.clamp(tangents[i - 1].dot(tangents[i]), -1, 1), 366 | ); // clamp for floating pt errors 367 | 368 | normals[i].applyMatrix4(mat.makeRotationAxis(vec, theta)); 369 | } 370 | 371 | binormals[i].crossVectors(tangents[i], normals[i]); 372 | } 373 | 374 | // if the curve is closed, postprocess the vectors so the first and last normal vectors are the same 375 | 376 | if (closed === true) { 377 | let theta = Math.acos( 378 | MathUtils.clamp(normals[0].dot(normals[segments]), -1, 1), 379 | ); 380 | theta /= segments; 381 | 382 | if ( 383 | tangents[0].dot(vec.crossVectors(normals[0], normals[segments])) > 0 384 | ) { 385 | theta = -theta; 386 | } 387 | 388 | for (let i = 1; i <= segments; i++) { 389 | // twist a little... 390 | normals[i].applyMatrix4(mat.makeRotationAxis(tangents[i], theta * i)); 391 | binormals[i].crossVectors(tangents[i], normals[i]); 392 | } 393 | } 394 | 395 | return { 396 | tangents: tangents, 397 | normals: normals, 398 | binormals: binormals, 399 | }; 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { Vector2, Vector3 } from "three"; 2 | import { LineTubeGeometry } from "./LineTubeGeometry"; 3 | import { LinePoint } from "./LinePoint"; 4 | import { SegmentColorizer, SimpleColorizer } from "./SegmentColorizer"; 5 | 6 | function getLength(lastPoint: Vector3, newPoint: Vector3) { 7 | const distant = 8 | (lastPoint.x - newPoint.x) ** 2 + 9 | (lastPoint.y - newPoint.y) ** 2 + 10 | (lastPoint.z - newPoint.z) ** 2; 11 | return distant ** 0.5; 12 | } 13 | 14 | /** 15 | * Parses a string cmd value. 16 | * The first char has to be a letter. 17 | * If value is not set (undefined | "") or if the resulting number is NaN, 18 | * the default value is returned. 19 | * 20 | * If the defaultValue is a number, the result can never be undefined due to type constraints. 21 | * 22 | * @param value 23 | * @param defaultValue {number | undefined} may be any number or undefined. 24 | * @returns if the defaultValue is undefined, this can be undefined. Else it will always be a number. 25 | */ 26 | function parseValue( 27 | value: string | undefined, 28 | defaultValue: DefaultType, 29 | ): number | DefaultType { 30 | if (!value) { 31 | return defaultValue; 32 | } 33 | const parsedValue = Number.parseFloat(value.substring(1)); 34 | return Number.isNaN(parsedValue) ? defaultValue : parsedValue; 35 | } 36 | 37 | /** 38 | * Recalculate the bounding box with the new point. 39 | * @param {Vector3} newPoint 40 | */ 41 | function calcMinMax( 42 | min: Vector3 | undefined, 43 | max: Vector3 | undefined, 44 | newPoint: Vector3, 45 | ): { min?: Vector3; max?: Vector3 } { 46 | const result = { min, max }; 47 | 48 | if (result.min === undefined) { 49 | result.min = newPoint.clone(); 50 | } 51 | if (result.max === undefined) { 52 | result.max = newPoint.clone(); 53 | } 54 | 55 | if (newPoint.x > result.max.x) { 56 | result.max.x = newPoint.x; 57 | } 58 | if (newPoint.y > result.max.y) { 59 | result.max.y = newPoint.y; 60 | } 61 | if (newPoint.z > result.max.z) { 62 | result.max.z = newPoint.z; 63 | } 64 | 65 | if (newPoint.x < result.min.x) { 66 | result.min.x = newPoint.x; 67 | } 68 | if (newPoint.y < result.min.y) { 69 | result.min.y = newPoint.y; 70 | } 71 | if (newPoint.z < result.min.z) { 72 | result.min.z = newPoint.z; 73 | } 74 | 75 | return result; 76 | } 77 | 78 | export interface LayerDefinition { 79 | start: number; 80 | end: number; 81 | } 82 | 83 | export enum LayerType { 84 | /** 85 | * Layers are defined by the Z value of the points. 86 | * This may not be accurate, and may have problems if used with Z-Hops or similar things. 87 | */ 88 | VARIABLE_Z, 89 | 90 | /** 91 | * Layer changes are defined by Layer comments in the GCode. 92 | * This is more accurate but requires the GCode to have layer comments. 93 | * ;LAYER:0 94 | */ 95 | LAYER_COMMENTS, 96 | } 97 | 98 | /** 99 | * GCode renderer which parses a GCode file and displays it using 100 | * three.js. Use .element() to retrieve the DOM canvas element. 101 | */ 102 | export class GCodeParser { 103 | private combinedLines: LineTubeGeometry[] = []; 104 | 105 | private gCode: string; 106 | 107 | public min?: Vector3; 108 | public max?: Vector3; 109 | 110 | private minTemp: number | undefined = undefined; 111 | private maxTemp = 0; 112 | private minSpeed: number | undefined = undefined; 113 | private maxSpeed = 0; 114 | 115 | // Public configurations: 116 | 117 | /** 118 | * Contains the start and end-point of each layer. 119 | * IMPORTANT: Do NOT MODIFY this array or it's , as it is used internally! 120 | * It is only meant to be read. 121 | */ 122 | public layerDefinition: LayerDefinition[] = []; 123 | 124 | /** 125 | * Width of travel-lines. Use 0 to hide them. 126 | * 127 | * @type number 128 | */ 129 | public travelWidth: number = 0.01; 130 | 131 | /** 132 | * Set any colorizer implementation to change the segment color based on the segment 133 | * metadata. Some default implementations are provided. 134 | * 135 | * @type SegmentColorizer 136 | */ 137 | public colorizer: SegmentColorizer = new SimpleColorizer(); 138 | 139 | /** 140 | * The number of radial segments per line. 141 | * Less (e.g. 3) provides faster rendering with less memory usage. 142 | * More (e.g. 8) provides a better look. 143 | * 144 | * @default 8 145 | * @type number 146 | */ 147 | public radialSegments: number = 8; 148 | 149 | /** 150 | * The layer type to determine how the layer change is detected. 151 | * @type LayerType 152 | * @default LayerType.VARIABLE_Z 153 | */ 154 | public layerType: LayerType = LayerType.VARIABLE_Z; 155 | 156 | /** 157 | * Internally the rendered object is split into several. This allows to reduce the 158 | * memory consumption while rendering. 159 | * You can set the number of points per object. 160 | * In most cases you can leave this at the default. 161 | * 162 | * @default 120000 163 | * @type number 164 | */ 165 | public pointsPerObject: number = 120000; 166 | 167 | /** 168 | * The nozzle offsets for multi-extrusion printers. 169 | * 170 | * @type Vector2[] 171 | */ 172 | public nozzleOffsets: Vector2[] = []; 173 | 174 | /** 175 | * Creates a new GCode renderer for the given gcode. 176 | * It initializes the canvas to the given size and 177 | * uses the passed color as background. 178 | * 179 | * @param {string} gCode 180 | * @param {number} width 181 | * @param {number} height 182 | * @param {Color} background 183 | */ 184 | constructor(gCode: string) { 185 | this.gCode = gCode; 186 | 187 | // Pre-calculate some min max values, needed for colorizing. 188 | this.calcMinMaxMetadata(); 189 | } 190 | 191 | /** 192 | * This can be used to retrieve some min / max values which may 193 | * be needed as param for a colorizer. 194 | * @returns {{ 195 | * minTemp: number | undefined, 196 | * maxTemp: number, 197 | * minSpeed: number | undefined, 198 | * maxSpeed: number 199 | * }} 200 | */ 201 | public getMinMaxValues() { 202 | return { 203 | minTemp: this.minTemp, 204 | maxTemp: this.maxTemp, 205 | minSpeed: this.minSpeed, 206 | maxSpeed: this.maxSpeed, 207 | }; 208 | } 209 | 210 | /** 211 | * Pre-calculates the min max metadata which may be needed for the colorizers. 212 | */ 213 | private calcMinMaxMetadata() { 214 | this.gCode.split("\n").forEach((line, i) => { 215 | if (line === undefined || line[0] === ";") { 216 | return; 217 | } 218 | 219 | const cmd = line.split(" "); 220 | if (cmd[0] === "G0" || cmd[0] === "G1") { 221 | // Feed rate -> speed 222 | const f = parseValue( 223 | cmd.find((v) => v[0] === "F"), 224 | undefined, 225 | ); 226 | 227 | if (f === undefined) { 228 | return; 229 | } 230 | 231 | if (f > this.maxSpeed) { 232 | this.maxSpeed = f; 233 | } 234 | if (this.minSpeed === undefined || f < this.minSpeed) { 235 | this.minSpeed = f; 236 | } 237 | } else if (cmd[0] === "M104" || cmd[0] === "M109") { 238 | // hot end temperature 239 | // M104 S205 ; set hot end temp 240 | // M109 S205 ; wait for hot end temp 241 | const hotendTemp = parseValue( 242 | cmd.find((v) => v[0] === "S"), 243 | 0, 244 | ); 245 | 246 | if (hotendTemp > this.maxTemp) { 247 | this.maxTemp = hotendTemp; 248 | } 249 | if (this.minTemp === undefined || hotendTemp < this.minTemp) { 250 | this.minTemp = hotendTemp; 251 | } 252 | } 253 | }); 254 | } 255 | 256 | /** 257 | * Reads the GCode and crates a mesh of it. 258 | */ 259 | public async parse() { 260 | // Cache the start and end of each layer. 261 | // Note: This may not work properly in some cases where the nozzle moves back down mid-print. 262 | const layerPointsCache: Map = 263 | new Map(); 264 | 265 | // Remember which values are in relative-mode. 266 | const relative = { x: false, y: false, z: false, e: false }; 267 | 268 | // Save some values 269 | let lastLastPoint: Vector3 = new Vector3(0, 0, 0); 270 | let lastPoint: Vector3 = new Vector3(0, 0, 0); 271 | let lastE = 0; 272 | let lastF = 0; 273 | let hotendTemp = 0; 274 | 275 | // Retrieves a value taking into account possible relative values. 276 | const getValue = ( 277 | cmd: string[], 278 | name: string, 279 | last: number, 280 | relative: boolean, 281 | ): number => { 282 | let val = parseValue( 283 | cmd.find((v) => v[0] === name), 284 | undefined, 285 | ); 286 | if (val !== undefined && relative) { 287 | val += last; 288 | } else if (val === undefined) { 289 | val = last; 290 | } 291 | 292 | if (Number.isNaN(val)) { 293 | throw new Error(`could not read the value in cmd '${cmd}'`); 294 | } 295 | return val; 296 | }; 297 | 298 | let lines: (string | undefined)[] = this.gCode.split("\n"); 299 | this.gCode = ""; // clear memory 300 | 301 | let currentObject = 0; 302 | let lastAddedLinePoint: LinePoint | undefined = undefined; 303 | let pointCount = 0; 304 | const addLine = (newLine: LinePoint) => { 305 | if (pointCount > 0 && pointCount % this.pointsPerObject === 0) { 306 | // end the old geometry and increase the counter 307 | this.combinedLines[currentObject].finish(); 308 | currentObject++; 309 | } 310 | 311 | if (this.combinedLines[currentObject] === undefined) { 312 | this.combinedLines[currentObject] = new LineTubeGeometry( 313 | this.radialSegments, 314 | ); 315 | if (lastAddedLinePoint) { 316 | this.combinedLines[currentObject].add(lastAddedLinePoint); 317 | } 318 | } 319 | this.combinedLines[currentObject].add(newLine); 320 | lastAddedLinePoint = newLine; 321 | pointCount++; 322 | }; 323 | 324 | const getCurrentNozzleOffset = (currentExtruder: number): Vector3 => { 325 | const offset = this.nozzleOffsets[currentExtruder] ?? new Vector2(0, 0); 326 | return new Vector3(offset.x, offset.y, 0); 327 | }; 328 | 329 | function* lineGenerator( 330 | travelWidth: number, 331 | colorizer: SegmentColorizer, 332 | layerType: LayerType, 333 | ): Generator<{ 334 | point: LinePoint; 335 | min?: Vector3; 336 | max?: Vector3; 337 | lineNumber: number; 338 | }> { 339 | let min: Vector3 | undefined; 340 | let max: Vector3 | undefined; 341 | let currentLayerIndex: number | undefined; 342 | let currentExtruder = 0; 343 | 344 | // Create the geometry. 345 | //this.combinedLines[oNr] = new LineTubeGeometry(this.radialSegments) 346 | for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { 347 | let line = lines[lineNumber]; 348 | 349 | if (line === undefined) { 350 | return; 351 | } 352 | 353 | if (layerType === LayerType.LAYER_COMMENTS) { 354 | if (line.startsWith(";")) { 355 | const layerMatch = line.match(/^;LAYER:(-?\d+)/); 356 | if (layerMatch) { 357 | let layerIndex = parseInt(layerMatch[1], 10); 358 | if (currentLayerIndex !== undefined) { 359 | let layerCache = layerPointsCache.get(currentLayerIndex); 360 | if (layerCache) { 361 | layerCache.end = pointCount - 1; 362 | } 363 | } 364 | currentLayerIndex = layerIndex; 365 | layerPointsCache.set(layerIndex, { start: pointCount, end: 0 }); 366 | } 367 | continue; 368 | } 369 | } 370 | 371 | line = line.split(";", 2)[0]; // split comments 372 | const cmd = line.split(" "); 373 | // A move command. 374 | if (cmd[0] === "G0" || cmd[0] === "G1") { 375 | const x = getValue(cmd, "X", lastPoint.x, relative.x); 376 | const y = getValue(cmd, "Y", lastPoint.y, relative.y); 377 | const z = getValue(cmd, "Z", lastPoint.z, relative.z); 378 | const e = getValue(cmd, "E", lastE, relative.e); 379 | const f = parseValue( 380 | cmd.find((v) => v[0] === "F"), 381 | lastF, 382 | ); 383 | 384 | const newPoint = new Vector3(x, y, z); 385 | const length = getLength(lastPoint, newPoint); 386 | 387 | if (length !== 0) { 388 | const radiusSquared = (e - lastE) / length; 389 | 390 | let radius = 0; 391 | // Hide negative extrusions as only move-extrusions 392 | if (radiusSquared < 0) { 393 | radius = 0; 394 | } 395 | if (radiusSquared === 0) { 396 | radius = travelWidth; 397 | } else { 398 | radius = Math.sqrt(radiusSquared); 399 | // Update the bounding box. 400 | const { min: newMin, max: newMax } = calcMinMax( 401 | min, 402 | max, 403 | newPoint, 404 | ); 405 | min = newMin; 406 | max = newMax; 407 | } 408 | 409 | // Get the color for this line. 410 | const color = colorizer.getColor({ 411 | radius, 412 | segmentStart: lastPoint, 413 | segmentEnd: newPoint, 414 | speed: f, 415 | temp: hotendTemp, 416 | gCodeLine: lineNumber, 417 | }); 418 | 419 | // Insert the last point with the current radius. 420 | // As the GCode contains the extrusion for the 'current' line, 421 | // but the LinePoint contains the radius for the 'next' line 422 | // we need to combine the last point with the current radius. 423 | yield { 424 | min, 425 | max, 426 | point: new LinePoint(lastPoint.clone().add(getCurrentNozzleOffset(currentExtruder)), radius, color), 427 | lineNumber, 428 | }; 429 | if (layerType == LayerType.VARIABLE_Z) { 430 | // Try to figure out the layer start and end points. 431 | if (lastPoint.z !== newPoint.z) { 432 | let last = layerPointsCache.get(lastPoint.z); 433 | let current = layerPointsCache.get(newPoint.z); 434 | 435 | if (last === undefined) { 436 | last = { 437 | end: 0, 438 | start: 0, 439 | }; 440 | } 441 | 442 | if (current === undefined) { 443 | current = { 444 | end: 0, 445 | start: 0, 446 | }; 447 | } 448 | 449 | last.end = pointCount - 1; 450 | current.start = pointCount; 451 | 452 | layerPointsCache.set(lastPoint.z, last); 453 | layerPointsCache.set(newPoint.z, current); 454 | } 455 | } 456 | } 457 | //save the data 458 | 459 | lastLastPoint.copy(lastPoint); 460 | lastPoint.copy(newPoint); 461 | lastE = e; 462 | lastF = f; 463 | 464 | // Set a value directly. 465 | } else if (cmd[0] === "G92") { 466 | // set state 467 | lastLastPoint.copy(lastPoint); 468 | lastPoint = new Vector3( 469 | parseValue( 470 | cmd.find((v) => v[0] === "X"), 471 | lastPoint.x, 472 | ), 473 | parseValue( 474 | cmd.find((v) => v[0] === "Y"), 475 | lastPoint.y, 476 | ), 477 | parseValue( 478 | cmd.find((v) => v[0] === "Z"), 479 | lastPoint.z, 480 | ), 481 | ); 482 | lastE = parseValue( 483 | cmd.find((v) => v[0] === "E"), 484 | lastE, 485 | ); 486 | 487 | // Hot end temperature. 488 | } else if (cmd[0] === "M104" || cmd[0] === "M109") { 489 | // M104 S205 ; start heating hot end 490 | // M109 S205 ; wait for hot end temperature 491 | hotendTemp = parseValue( 492 | cmd.find((v) => v[0] === "S"), 493 | 0, 494 | ); 495 | 496 | // Absolute axes 497 | } else if (cmd[0] === "G90") { 498 | relative.x = false; 499 | relative.y = false; 500 | relative.z = false; 501 | relative.e = false; 502 | 503 | // Relative axes 504 | } else if (cmd[0] === "G91") { 505 | relative.x = true; 506 | relative.y = true; 507 | relative.z = true; 508 | relative.e = true; 509 | 510 | // Absolute extrusion 511 | } else if (cmd[0] === "M82") { 512 | relative.e = false; 513 | 514 | // Relative extrusion 515 | } else if (cmd[0] === "M83") { 516 | relative.e = true; 517 | 518 | // Inch values 519 | } else if (cmd[0] === "G20") { 520 | // TODO: inch values 521 | throw new Error("inch values not implemented yet"); 522 | } else if (cmd[0].startsWith("T")) { 523 | // Tool change 524 | currentExtruder = parseInt(cmd[0].substring(1), 10); 525 | } 526 | 527 | lines[lineNumber] = undefined; 528 | } 529 | } 530 | 531 | let gen = lineGenerator(this.travelWidth, this.colorizer, this.layerType); 532 | const delay = (ms: number) => 533 | new Promise((resolve) => setTimeout(resolve, ms)); 534 | 535 | for (let job of gen) { 536 | this.min = job.min; 537 | this.max = job.max; 538 | addLine(job.point); 539 | 540 | // Add a small delay every 200 lines to allow the UI to update. 541 | if (job.lineNumber % 200 === 1) { 542 | await delay(0); 543 | } 544 | } 545 | 546 | // Finish last object 547 | if (this.combinedLines[currentObject]) { 548 | this.combinedLines[currentObject].finish(); 549 | } 550 | 551 | // Sort the layers by starting line number. 552 | this.layerDefinition = Array.from(layerPointsCache.values()).sort( 553 | (v1, v2) => v1.start - v2.start, 554 | ); 555 | // Set the end of the last layer correctly. 556 | this.layerDefinition[this.layerDefinition.length - 1].end = 557 | this.pointsCount() - 1; 558 | } 559 | /** 560 | * Slices the rendered model based on the passed start and end point numbers. 561 | * (0, pointsCount()) renders everything 562 | * 563 | * Note: Currently negative values are not allowed. 564 | * 565 | * @param {number} start the starting segment 566 | * @param {number} end the ending segment (excluding) 567 | */ 568 | public slice(start: number = 0, end: number = this.pointsCount()) { 569 | // TODO: support negative values like the slice from Array? 570 | if (start < 0 || end < 0) { 571 | throw new Error("negative values are not supported, yet"); 572 | } 573 | 574 | const objectStart = Math.floor(start / this.pointsPerObject); 575 | const objectEnd = Math.ceil(end / this.pointsPerObject) - 1; 576 | 577 | this.combinedLines.forEach((line, i) => { 578 | // Render nothing if both are the same (and not undefined) 579 | if (start !== undefined && start === end) { 580 | line.slice(0, 0); 581 | return; 582 | } 583 | 584 | let from = 0; 585 | let to = line.pointsCount(); 586 | 587 | if (i == objectStart) { 588 | from = start - i * this.pointsPerObject; 589 | // If it is not the first object, remove the first point from the calculation. 590 | if (objectStart > 0) { 591 | from++; 592 | } 593 | } 594 | 595 | if (i == objectEnd) { 596 | to = end - i * this.pointsPerObject; 597 | // Only if it is not the last object, add the last point to the calculation. 598 | if ( 599 | objectEnd <= Math.floor(this.pointsCount() / this.pointsPerObject) 600 | ) { 601 | to++; 602 | } 603 | } 604 | 605 | if (i < objectStart || i > objectEnd) { 606 | from = 0; 607 | to = 0; 608 | } 609 | 610 | line.slice(from, to); 611 | }); 612 | } 613 | 614 | /** 615 | * Slices the rendered model based on the passed start and end line numbers. 616 | * (0, layerCount()) renders everything 617 | * 618 | * Note: Currently negative values are not allowed. 619 | * 620 | * @param {number} start the starting layer 621 | * @param {number} end the ending layer (excluding) 622 | */ 623 | public sliceLayer(start?: number, end?: number) { 624 | this.slice( 625 | typeof start === "number" 626 | ? this.layerDefinition[start]?.start 627 | : undefined, 628 | typeof end === "number" ? this.layerDefinition[end]?.end + 1 : undefined, 629 | ); 630 | } 631 | 632 | /** 633 | * disposes everything which is dispose able. 634 | * Call this always before destroying the instance."" 635 | */ 636 | public dispose() { 637 | this.combinedLines.forEach((e) => e.dispose()); 638 | } 639 | 640 | /** 641 | * Get the amount of points in the model. 642 | * 643 | * @returns {number} 644 | */ 645 | public pointsCount(): number { 646 | return this.combinedLines.reduce((count, line, i) => { 647 | // Do not count the first point of all objects after the first one. 648 | // This point is always the same as the last from the previous object. 649 | // The very first point is still counted -> i > 0. 650 | if (i > 0) { 651 | return count + line.pointsCount() - 1; 652 | } 653 | return count + line.pointsCount(); 654 | }, 0); 655 | } 656 | 657 | /** 658 | * Get the amount of layers in the model. 659 | * This is an approximation which may be incorrect if the 660 | * nozzle moves downwards mid print. 661 | * 662 | * @returns {number} 663 | */ 664 | public layerCount(): number { 665 | // the last layer contains only the start-point, not an end point. -> -1 666 | return this.layerDefinition.length - 1 || 0; 667 | } 668 | 669 | /** 670 | * You can get the internal geometries generated from the gcode. 671 | * Use only if you know what you do. 672 | * @returns the internal generated geometries. 673 | */ 674 | public getGeometries() { 675 | return this.combinedLines; 676 | } 677 | } 678 | --------------------------------------------------------------------------------