diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 9acc17a3..00000000 --- a/.gitpod.yml +++ /dev/null @@ -1,9 +0,0 @@ -# This configuration file was automatically generated by Gitpod. -# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) -# and commit this file to your remote git repository to share the goodness with others. - -tasks: - - init: npm install && npm run build - command: npm run watch - - diff --git a/demo/js/app.js b/demo/js/app.js index 41592253..aabfbc6d 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -53,7 +53,7 @@ export const app = (window.app = createApp({ const updateUI = async () => { const { parser, - layers, + countLayers, extrusionColor, topLayerColor, lastSegmentColor, @@ -70,11 +70,11 @@ export const app = (window.app = createApp({ const { thumbnails } = parser.metadata; thumbnail.value = thumbnails['220x124']?.src; - layerCount.value = layers.length; + layerCount.value = countLayers; const colors = extrusionColor instanceof Array ? extrusionColor : [extrusionColor]; const currentSettings = { - maxLayer: layers.length, - endLayer: layers.length, + maxLayer: countLayers, + endLayer: countLayers, singleLayerMode, renderTravel, travelColor: '#' + travelColor.getHexString(), @@ -93,7 +93,7 @@ export const app = (window.app = createApp({ }; Object.assign(settings.value, currentSettings); - preview.endLayer = layers.length; + preview.endLayer = countLayers; }; const loadGCodeFromServer = async (filename) => { @@ -112,7 +112,8 @@ export const app = (window.app = createApp({ const prevDevMode = preview.devMode; preview.clear(); preview.devMode = prevDevMode; - preview.parser.parseGCode(gcode); + const { commands } = preview.parser.parseGCode(gcode); + preview.interpreter.execute(commands, preview.job); render(); }; @@ -120,7 +121,12 @@ export const app = (window.app = createApp({ const render = async () => { debounce(async () => { if (loadProgressive) { - await preview.renderAnimated(Math.ceil(preview.layers.length / 60)); + if (preview.job.layers === null) { + console.warn('Job is not planar'); + preview.render(); + return; + } + await preview.renderAnimated(2000); } else { preview.render(); } @@ -190,9 +196,6 @@ export const app = (window.app = createApp({ }); watchEffect(() => { - preview.startLayer = +settings.value.startLayer; - preview.endLayer = +settings.value.endLayer; - preview.singleLayerMode = settings.value.singleLayerMode; preview.renderExtrusion = settings.value.renderExtrusion; preview.travelColor = settings.value.travelColor; @@ -207,6 +210,12 @@ export const app = (window.app = createApp({ render(); }); + + watchEffect(() => { + preview.startLayer = +settings.value.startLayer; + preview.endLayer = +settings.value.endLayer; + preview.singleLayerMode = settings.value.singleLayerMode; + }); }); return { diff --git a/demo/js/presets.js b/demo/js/presets.js index a42d6f62..2594209a 100644 --- a/demo/js/presets.js +++ b/demo/js/presets.js @@ -14,7 +14,7 @@ export const presets = { mach3: { title: 'CNC tool path', file: 'gcodes/mach3.gcode', - lineWidth: 1, + lineWidth: 2, renderExtrusion: false, renderTravel: true, travelColor: '#00FF00', @@ -43,6 +43,8 @@ export const presets = { title: 'Vase mode', file: 'gcodes/vase.gcode', lineWidth: 0, + lineHeight: 0.4, + minLayerThreshold: 0.6, renderExtrusion: true, renderTubes: true, extrusionColor: ['rgb(84,74,187)'], @@ -60,7 +62,7 @@ export const presets = { 'travel-moves': { title: 'Travel moves', file: 'gcodes/plant-sign.gcode', - lineWidth: 0, + lineWidth: 1, renderExtrusion: true, renderTubes: true, extrusionColor: ['#777777'], diff --git a/line2-segments b/line2-segments deleted file mode 100644 index 57c33308..00000000 --- a/line2-segments +++ /dev/null @@ -1,24 +0,0 @@ - -input -A [p0,p1,p1,p2] // connected -B [p0,p1,p2,p3] // disconnected - -output -A [[p0,p1,p2]] // connected & shortened -B [[p0, p1], [p2,p3]] // disconnected - -// start array -var all = []; - -if (all.length == 0) - // start pair -var line = [p0, p1] -all.push(line) - -next pair - -p2, p3 -if (p1 == p2) -// add to current coninuous line - -else // create new line segment diff --git a/src/__tests__/gcode-parser.ts b/src/__tests__/gcode-parser.ts index c6177f78..1af316ab 100644 --- a/src/__tests__/gcode-parser.ts +++ b/src/__tests__/gcode-parser.ts @@ -1,243 +1,126 @@ import { test, expect } from 'vitest'; -import { GCodeCommand, MoveCommand, Parser, SelectToolCommand } from '../gcode-parser'; +import { GCodeCommand, Parser } from '../gcode-parser'; -test('a single extrusion cmd should result in 1 layer with 1 command', () => { - const parser = new Parser(0); +test('a single extrusion cmd should result in 1 command', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1`; const parsed = parser.parseGCode(gcode); expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); + expect(parsed.commands).not.toBeNull(); + expect(parsed.commands.length).toEqual(1); }); -test('a gcode cmd w/o extrusion should not result in a layer', () => { - const parser = new Parser(0); - const gcode = `G1 X0 Y0 Z1`; +test('a single extrusion cmd should parse attributes', () => { + const parser = new Parser(); + const gcode = `G1 X5 Y6 Z3 E1.9`; const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(0); -}); - -test('a gcode cmd with 0 extrusion should not result in a layer', () => { - const parser = new Parser(0); - const gcode = `G1 X0 Y0 Z1 E0`; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(0); + const cmd = parsed.commands[0]; + expect(cmd.params.x).toEqual(5); + expect(cmd.params.y).toEqual(6); + expect(cmd.params.z).toEqual(3); + expect(cmd.params.e).toEqual(1.9); }); -test('2 horizontal extrusion moves should result in 1 layer with 2 commands', () => { - const parser = new Parser(0); - const gcode = `G1 X0 Y0 Z1 E1 - G1 X10 Y10 Z1 E2`; +test('multiple cmd results in an array of commands', () => { + const parser = new Parser(); + const gcode = `G1 X5 Y6 Z3 E1.9 + G1 X6 Y6 E1.9 + G1 X5 Y7 E1.9`; const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(2); -}); - -test('2 vertical extrusion moves should result in 2 layers with 1 command', () => { - const parser = new Parser(0); - const gcode = `G1 X0 Y0 Z1 E1 - G1 X0 Y0 Z2 E2`; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(2); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); -}); - -test('2 vertical extrusion moves in consecutive gcode chunks should result in 2 layers with 1 command', () => { - const parser = new Parser(0); - const gcode1 = 'G1 X0 Y0 Z1 E1'; - const gcode2 = 'G1 X0 Y0 Z2 E2'; - const parsed = parser.parseGCode(gcode1); - parser.parseGCode(gcode2); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(2); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); -}); - -test('2 vertical extrusion moves in consecutive gcode chunks as string arrays should result in 2 layers with 1 command', () => { - const parser = new Parser(0); - const gcode1 = ['G1 X0 Y0 Z1 E1']; - const gcode2 = ['G1 X0 Y0 Z2 E2']; - const parsed = parser.parseGCode(gcode1); - parser.parseGCode(gcode2); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(2); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); -}); - -test('2 extrusion moves with a z difference below the threshold should result in only 1 layer', () => { - const threshold = 1; - const parser = new Parser(threshold); - const gcode = `G1 X0 Y0 Z1 E1 - G1 X10 Y10 Z1.5 E2`; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(2); -}); - -test('2 extrusion moves with a z difference above the threshold should result in 2 layers', () => { - const threshold = 1; - const parser = new Parser(threshold); - const gcode = `G1 X0 Y0 Z1 E1 - G1 X10 Y10 Z3 E2`; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(2); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(1); -}); - -test('2 extrusion moves with a z diff exactly at the threshold should result in 1 layer', () => { - const threshold = 1; - const parser = new Parser(threshold); - const gcode = `G1 X0 Y0 Z1 E1 - G1 X10 Y10 Z2 E2`; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); - expect(parsed.layers[0].commands.length).toEqual(2); -}); - -test('Layers should have calculated heights', () => { - const threshold = 0.05; - const parser = new Parser(threshold); - const gcode = `G0 X0 Y0 Z0.1 E1 - G1 X10 Y10 Z0.2 E2 - G1 X20 Y20 Z0.3 E3 - G1 X30 Y30 Z0.5 E4 - G1 X40 Y40 Z0.8 E5 - `; - const parsed = parser.parseGCode(gcode); - expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(5); - expect(parsed.layers[0].height).toEqual(expect.closeTo(0.1, 3)); - expect(parsed.layers[1].height).toEqual(expect.closeTo(0.1, 3)); - expect(parsed.layers[2].height).toEqual(expect.closeTo(0.1, 3)); - expect(parsed.layers[3].height).toEqual(expect.closeTo(0.2, 3)); + expect(parsed.commands).not.toBeNull(); + expect(parsed.commands.length).toEqual(3); }); -test('T0 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T0 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T0`; const parsed = parser.parseGCode(gcode); expect(parsed).not.toBeNull(); - expect(parsed.layers).not.toBeNull(); - expect(parsed.layers.length).toEqual(1); - expect(parsed.layers[0].commands).not.toBeNull(); + expect(parsed.commands).not.toBeNull(); + expect(parsed.commands.length).toEqual(2); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t0'); - expect(cmd.toolIndex).toEqual(0); }); -test('T1 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T1 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T1`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t1'); - expect(cmd.toolIndex).toEqual(1); }); -test('T2 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T2 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T2`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t2'); - expect(cmd.toolIndex).toEqual(2); }); -test('T3 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T3 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T3`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t3'); - expect(cmd.toolIndex).toEqual(3); }); // repeat fot T4 .. T7 -test('T4 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T4 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T4`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t4'); - expect(cmd.toolIndex).toEqual(4); }); -test('T5 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T5 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T5`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t5'); - expect(cmd.toolIndex).toEqual(5); }); -test('T6 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T6 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T6`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t6'); - expect(cmd.toolIndex).toEqual(6); }); -test('T7 command should result in a tool change to tool with index 0', () => { - const parser = new Parser(0); +test('T7 command should result in a tool change', () => { + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1 T7`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[1] as SelectToolCommand; + const cmd = parsed.commands[1]; expect(cmd.gcode).toEqual('t7'); - expect(cmd.toolIndex).toEqual(7); }); test('gcode commands with spaces between letters and numbers should be parsed correctly', () => { - const parser = new Parser(0); + const parser = new Parser(); const gcode = `G 1 E 42 X 42`; const parsed = parser.parseGCode(gcode); - const cmd = parsed.layers[0].commands[0] as MoveCommand; + const cmd = parsed.commands[0]; expect(cmd.gcode).toEqual('g1'); expect(cmd.params.x).toEqual(42); + expect(cmd.params.e).toEqual(42); }); // test that a line withouth a gcode command results in a command with empty string gcode test('gcode commands without gcode should result in a command with empty string gcode', () => { - const parser = new Parser(0); + const parser = new Parser(); const gcode = ` ; comment`; const cmd = parser.parseCommand(gcode) as GCodeCommand; expect(cmd.gcode).toEqual(''); diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts new file mode 100644 index 00000000..16cc044c --- /dev/null +++ b/src/__tests__/interpreter.ts @@ -0,0 +1,322 @@ +import { test, expect, describe } from 'vitest'; +import { GCodeCommand } from '../gcode-parser'; +import { Interpreter } from '../interpreter'; +import { Job } from '../job'; +import { PathType } from '../path'; + +describe('.execute', () => { + test('returns a stateful job', () => { + const command = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const interpreter = new Interpreter(); + + const result = interpreter.execute([command]); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Job); + expect(result.state.x).toEqual(1); + expect(result.state.y).toEqual(2); + expect(result.state.z).toEqual(3); + }); + + test('ignores unknown commands', () => { + const command = new GCodeCommand('G42', 'g42', {}); + const interpreter = new Interpreter(); + + const result = interpreter.execute([command]); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Job); + expect(result.state.x).toEqual(0); + expect(result.state.y).toEqual(0); + expect(result.state.z).toEqual(0); + }); + + test('runs multiple commands', () => { + const command1 = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const command2 = new GCodeCommand('G0 X4 Y5 Z6', 'g0', { x: 4, y: 5, z: 6 }); + const interpreter = new Interpreter(); + + const result = interpreter.execute([command1, command2, command1, command2]); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Job); + expect(result.state.x).toEqual(4); + expect(result.state.y).toEqual(5); + expect(result.state.z).toEqual(6); + }); + + test('runs on an existing job', () => { + const job = new Job(); + const command = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const interpreter = new Interpreter(); + + const result = interpreter.execute([command], job); + + expect(result).toEqual(job); + expect(result.state.x).toEqual(1); + expect(result.state.y).toEqual(2); + expect(result.state.z).toEqual(3); + }); + + test('finishes the current path at the end of the job', () => { + const job = new Job(); + const command = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const interpreter = new Interpreter(); + interpreter.execute([command], job); + + expect(job.paths.length).toEqual(1); + expect(job.inprogressPath).toBeUndefined(); + }); + + test('resumes the current path when doing incremental execution', () => { + const job = new Job(); + const command1 = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const command2 = new GCodeCommand('G0 X4 Y5 Z6', 'g0', { x: 4, y: 5, z: 6 }); + const interpreter = new Interpreter(); + + interpreter.execute([command1], job); + interpreter.execute([command2], job); + + expect(job.paths.length).toEqual(1); + expect(job.paths[0].vertices.length).toEqual(9); + expect(job.paths[0].vertices[6]).toEqual(command2.params.x); + expect(job.paths[0].vertices[7]).toEqual(command2.params.y); + expect(job.paths[0].vertices[8]).toEqual(command2.params.z); + }); +}); + +describe('.g0', () => { + test('starts a path if the job has none, starting at the job current state', () => { + const command = new GCodeCommand('G0 X1 Y2', 'g0', { x: 1, y: 2 }); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.x = 3; + job.state.y = 4; + job.state.tool = 5; + + interpreter.g0(command, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.vertices.length).toEqual(6); + expect(job.inprogressPath?.vertices[0]).toEqual(3); + expect(job.inprogressPath?.vertices[1]).toEqual(4); + expect(job.inprogressPath?.vertices[2]).toEqual(0); + expect(job.inprogressPath?.tool).toEqual(5); + }); + + test('continues the path if the job has one', () => { + const command1 = new GCodeCommand('G0 X1 Y2', 'g0', { x: 1, y: 2 }); + const command2 = new GCodeCommand('G0 X3 Y4', 'g0', { x: 3, y: 4 }); + const interpreter = new Interpreter(); + const job = new Job(); + + job.state.z = 5; + interpreter.g0(command1, job); + + interpreter.g0(command2, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.vertices.length).toEqual(9); + expect(job.inprogressPath?.vertices[6]).toEqual(command2.params.x); + expect(job.inprogressPath?.vertices[7]).toEqual(command2.params.y); + expect(job.inprogressPath?.vertices[8]).toEqual(job.state.z); + }); + + test("assigns the travel type if there's no extrusion", () => { + const command = new GCodeCommand('G0 X1 Y2', 'g0', { x: 1, y: 2 }); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g0(command, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual(PathType.Travel); + }); + + test("assigns the extrusion type if there's extrusion", () => { + const command = new GCodeCommand('G1 X1 Y2 E3', 'g1', { x: 1, y: 2, e: 3 }); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g0(command, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual('Extrusion'); + }); + + test('assigns the travel type if the extrusion is a retraction', () => { + const command = new GCodeCommand('G0 E-2', 'g0', { e: -2 }); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g0(command, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual('Travel'); + }); + + test('assigns the travel type if the extrusion is a retraction', () => { + const command = new GCodeCommand('G0 E-2', 'g0', { e: -2 }); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g0(command, job); + + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual('Travel'); + }); + + test('starts a new path if the travel type changes from Travel to Extrusion', () => { + const command1 = new GCodeCommand('G0 X1 Y2', 'g0', { x: 1, y: 2 }); + const command2 = new GCodeCommand('G1 X3 Y4 E5', 'g1', { x: 3, y: 4, e: 5 }); + const interpreter = new Interpreter(); + const job = new Job(); + interpreter.execute([command1], job); + + interpreter.g0(command2, job); + + expect(job.paths.length).toEqual(1); + expect(job.inprogressPath?.travelType).toEqual('Extrusion'); + }); + + test('starts a new path if the travel type changes from Extrusion to Travel', () => { + const command1 = new GCodeCommand('G1 X1 Y2 E3', 'g1', { x: 1, y: 2, e: 3 }); + const command2 = new GCodeCommand('G0 X3 Y4', 'g0', { x: 3, y: 4 }); + const interpreter = new Interpreter(); + const job = new Job(); + interpreter.execute([command1], job); + + interpreter.g0(command2, job); + + expect(job.paths.length).toEqual(1); + expect(job.inprogressPath?.travelType).toEqual('Travel'); + }); + + test('.G1 is an alias to .G0', () => { + const interpreter = new Interpreter(); + + expect(interpreter.g1).toEqual(interpreter.g0); + }); +}); + +test('.G20 sets the units to inches', () => { + const command = new GCodeCommand('G20', 'g20', {}); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g20(command, job); + + expect(job.state.units).toEqual('in'); +}); + +test('.G21 sets the units to millimeters', () => { + const command = new GCodeCommand('G21', 'g21', {}); + const interpreter = new Interpreter(); + const job = new Job(); + + interpreter.g21(command, job); + + expect(job.state.units).toEqual('mm'); +}); + +test('.g28 moves the state to the origin', () => { + const command = new GCodeCommand('G28', 'g28', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.x = 3; + job.state.y = 4; + + interpreter.g28(command, job); + + expect(job.state.x).toEqual(0); + expect(job.state.y).toEqual(0); + expect(job.state.z).toEqual(0); +}); + +test('.t0 sets the tool to 0', () => { + const command = new GCodeCommand('T0', 't0', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t0(command, job); + + expect(job.state.tool).toEqual(0); +}); + +test('.t1 sets the tool to 1', () => { + const command = new GCodeCommand('T1', 't1', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t1(command, job); + + expect(job.state.tool).toEqual(1); +}); + +test('.t2 sets the tool to 2', () => { + const command = new GCodeCommand('T2', 't2', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t2(command, job); + + expect(job.state.tool).toEqual(2); +}); + +test('.t3 sets the tool to 3', () => { + const command = new GCodeCommand('T3', 't3', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t3(command, job); + + expect(job.state.tool).toEqual(3); +}); + +test('.t4 sets the tool to 4', () => { + const command = new GCodeCommand('T4', 't4', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t4(command, job); + + expect(job.state.tool).toEqual(4); +}); + +test('.t5 sets the tool to 5', () => { + const command = new GCodeCommand('T5', 't5', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t5(command, job); + + expect(job.state.tool).toEqual(5); +}); + +test('.t6 sets the tool to 6', () => { + const command = new GCodeCommand('T6', 't6', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t6(command, job); + + expect(job.state.tool).toEqual(6); +}); + +test('.t7 sets the tool to 7', () => { + const command = new GCodeCommand('T7', 't7', {}); + const interpreter = new Interpreter(); + const job = new Job(); + job.state.tool = 3; + + interpreter.t7(command, job); + + expect(job.state.tool).toEqual(7); +}); diff --git a/src/__tests__/job.ts b/src/__tests__/job.ts new file mode 100644 index 00000000..d14743e4 --- /dev/null +++ b/src/__tests__/job.ts @@ -0,0 +1,435 @@ +import { test, expect, describe } from 'vitest'; +import { Job, State, LayersIndexer } from '../job'; +import { PathType, Path } from '../path'; + +test('it has an initial state', () => { + const job = new Job(); + + expect(job.state).toEqual(State.initial); +}); + +describe('.isPlanar', () => { + test('returns true if all extrusions are on the same plane', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Extrusion, [ + [1, 2, 0], + [5, 6, 0] + ]); + + expect(job.isPlanar()).toEqual(true); + }); + + test('returns false if any extrusions are on a different plane', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Extrusion, [ + [1, 2, 0], + [5, 6, 1] + ]); + + expect(job.isPlanar()).toEqual(false); + }); + + test('ignores travel paths', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 1], + [1, 2, 0] + ]); + append_path(job, PathType.Extrusion, [ + [1, 2, 0], + [5, 6, 0] + ]); + + expect(job.isPlanar()).toEqual(true); + }); +}); + +describe('.layers', () => { + test('returns null if the job is not planar', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Extrusion, [ + [5, 6, 0], + [5, 6, 1] + ]); + + expect(job.layers).toEqual([]); + }); + + test('paths without z changes are on the same layer', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); + }); + + test('extrusion paths moving z above the default tolerance create a new layer', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Extrusion, [ + [5, 6, LayersIndexer.DEFAULT_TOLERANCE + 0.02], + [5, 6, LayersIndexer.DEFAULT_TOLERANCE + 0.02] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(2); + expect(layers[0].paths.length).toEqual(1); + expect(layers[1].paths.length).toEqual(1); + }); + + test('travel paths moving z under the default tolerance are on the same layer', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, LayersIndexer.DEFAULT_TOLERANCE - 0.01] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); + }); + + test('Tolerance can be set', () => { + const job = new Job({ minLayerThreshold: 0.1 }); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0.09] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); + }); + + test('multiple travels in a row are on the same layer', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 2] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 2], + [5, 6, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 2] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(4); + }); + + test('extrusions after travels are on the same layer', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 2] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 2], + [5, 6, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 2] + ]); + append_path(job, PathType.Extrusion, [ + [5, 6, 2], + [5, 6, 2] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers.length).toEqual(2); + expect(layers[0].paths.length).toEqual(4); + expect(layers[1].paths.length).toEqual(1); + }); + + test('initial travels are on the same layer as the first extrusion', () => { + const job = new Job(); + + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 2], + [5, 6, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 2] + ]); + append_path(job, PathType.Extrusion, [ + [5, 6, 2], + [5, 6, 2] + ]); + + const layers = job.layers; + + expect(layers).not.toBeNull(); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(4); + }); +}); + +describe('.extrusions', () => { + test('returns all extrusion paths', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0] + ]); + append_path(job, PathType.Extrusion, [ + [1, 2, 0], + [5, 6, 0] + ]); + + const extrusions = job.extrusions; + + expect(extrusions).not.toBeNull(); + expect(extrusions).toBeInstanceOf(Array); + expect(extrusions.length).toEqual(2); + extrusions.forEach((path) => { + expect(path.travelType).toEqual(PathType.Extrusion); + }); + }); +}); + +describe('.travels', () => { + test('returns all travel paths', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [ + [0, 0, 0], + [1, 2, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0] + ]); + append_path(job, PathType.Extrusion, [ + [1, 2, 0], + [5, 6, 0] + ]); + append_path(job, PathType.Travel, [ + [5, 6, 0], + [5, 6, 0] + ]); + + const travels = job.travels; + + expect(travels).not.toBeNull(); + expect(travels).toBeInstanceOf(Array); + expect(travels.length).toEqual(2); + travels.forEach((path) => { + expect(path.travelType).toEqual(PathType.Travel); + }); + }); +}); + +describe('.toolPaths', () => { + test('Extrusions using the same tool are indexed', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [], 0); + append_path(job, PathType.Extrusion, [], 1); + append_path(job, PathType.Extrusion, [], 0); + append_path(job, PathType.Extrusion, [], 1); + append_path(job, PathType.Extrusion, [], 5); + append_path(job, PathType.Extrusion, [], 2); + append_path(job, PathType.Extrusion, [], 2); + + const toolPaths = job.toolPaths; + + expect(toolPaths).not.toBeNull(); + expect(toolPaths).toBeInstanceOf(Array); + expect(toolPaths.length).toEqual(6); + expect(toolPaths[0].length).toEqual(2); + expect(toolPaths[1].length).toEqual(2); + expect(toolPaths[2].length).toEqual(2); + expect(toolPaths[3]).toBeUndefined(); + expect(toolPaths[4]).toBeUndefined(); + expect(toolPaths[5].length).toEqual(1); + }); +}); + +describe('.addPath', () => { + test('adds the path to the job', () => { + const job = new Job(); + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + + job.addPath(path); + + expect(job.paths).toEqual([path]); + }); + + test('indexes the path', () => { + const job = new Job(); + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + + job.addPath(path); + + expect(job.extrusions).toEqual([path]); + }); +}); + +describe('.finishPath', () => { + test('does nothing if there is no in progress path', () => { + const job = new Job(); + + job.finishPath(); + + expect(job.paths).toEqual([]); + }); + + test('adds the in progress path to the job', () => { + const job = new Job(); + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + + path.addPoint(0, 0, 0); + + job.inprogressPath = path; + job.finishPath(); + + expect(job.paths).toEqual([path]); + }); + + test('ignores empty paths', () => { + const job = new Job(); + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + + job.inprogressPath = path; + job.finishPath(); + + expect(job.paths).toEqual([]); + }); + + test('clears the in progress path', () => { + const job = new Job(); + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + + path.addPoint(0, 0, 0); + + job.inprogressPath = path; + job.finishPath(); + + expect(job.inprogressPath).toBeUndefined(); + }); +}); + +describe('.resumeLastPath', () => { + test('pops the last path and makes it in progress', () => { + const job = new Job(); + + job.resumeLastPath(); + + expect(job.paths).toEqual([]); + }); + + test('adds the in progress path to the job', () => { + const job = new Job(); + + const path = append_path(job, PathType.Extrusion, [[0, 0, 0]]); + + job.resumeLastPath(); + + expect(job.inprogressPath).toEqual(path); + expect(job.paths).toEqual([]); + }); + + test('the path is removed from indexes to not appear twice', () => { + const job = new Job(); + + append_path(job, PathType.Extrusion, [[0, 0, 0]]); + job.resumeLastPath(); + + expect(job.extrusions).toEqual([]); + expect(job.layers[job.layers.length - 1].paths).toEqual([]); + }); +}); + +function append_path(job: Job, travelType, points: [number, number, number][], tool: number = 0): Path { + const path = new Path(travelType, 0.6, 0.2, tool || job.state.tool); + points.forEach((point: [number, number, number]) => path.addPoint(...point)); + job.addPath(path); + return path; +} diff --git a/src/__tests__/path.ts b/src/__tests__/path.ts new file mode 100644 index 00000000..0684e84e --- /dev/null +++ b/src/__tests__/path.ts @@ -0,0 +1,159 @@ +import { test, expect, describe } from 'vitest'; +import { Path, PathType } from '../path'; +import { ExtrusionGeometry } from '../extrusion-geometry'; +import { BufferGeometry } from 'three'; + +test('.addPoint adds a point to the vertices', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(1, 2, 3); + + expect(path.vertices).not.toBeNull(); + expect(path.vertices.length).toEqual(3); +}); + +test('.addPoint adds points at the end of vertices', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + path.addPoint(5, 6, 7); + + expect(path.vertices).not.toBeNull(); + expect(path.vertices.length).toEqual(9); + expect(path.vertices[6]).toEqual(5); + expect(path.vertices[7]).toEqual(6); + expect(path.vertices[8]).toEqual(7); +}); + +test('.checkLineContinuity returns false if there are less than 3 vertices', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + expect(path.checkLineContinuity(0, 0, 0)).toBeFalsy(); +}); + +test('.checkLineContinuity returns false if the last point is different', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + expect(path.checkLineContinuity(1, 2, 4)).toBeFalsy(); +}); + +test('.checkLineContinuity returns true if the last point is the same', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + expect(path.checkLineContinuity(1, 2, 3)).toBeTruthy(); +}); + +test('.path returns an array of Vector3', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.path(); + + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result[0]).toEqual({ x: 0, y: 0, z: 0 }); + expect(result[1]).toEqual({ x: 1, y: 2, z: 3 }); +}); + +describe('.geometry', () => { + test('returns an ExtrusionGeometry from the path', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.geometry() as ExtrusionGeometry; + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(ExtrusionGeometry); + expect(result.parameters.points.length).toEqual(2); + expect(result.parameters.closed).toEqual(false); + }); + + test('returns an ExtrusionGeometry with the path extrusion width', () => { + const path = new Path(PathType.Travel, 9, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.geometry() as ExtrusionGeometry; + + expect(result.parameters.lineWidth).toEqual(9); + }); + + test('returns an ExtrusionGeometry with the path line height', () => { + const path = new Path(PathType.Travel, undefined, 5, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.geometry() as ExtrusionGeometry; + + expect(result.parameters.lineHeight).toEqual(5); + }); + + test('returns an ExtrusionGeometry with the extrusionWidthOverride when passed', () => { + const path = new Path(PathType.Travel, 9, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.geometry({ extrusionWidthOverride: 2 }) as ExtrusionGeometry; + + expect(result.parameters.lineWidth).toEqual(2); + }); + + test('returns an ExtrusionGeometry with the lineHeightOverride when passed', () => { + const path = new Path(PathType.Travel, undefined, 5, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.geometry({ lineHeightOverride: 7 }) as ExtrusionGeometry; + + expect(result.parameters.lineHeight).toEqual(7); + }); + + test('returns an empty BufferGeometry if there are less than 3 vertices', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + const result = path.geometry(); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(BufferGeometry); + }); +}); + +describe('.line', () => { + test('returns a BufferGeometry from the path', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); + + const result = path.line(); + + expect(result).not.toBeNull(); + const points = result.attributes['instanceStart'].array; + expect(points).toEqual(Float32Array.from([0, 0, 0, 1, 2, 3])); + }); + + test('returns a BufferGeometry when there are no vertices', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + const result = path.line(); + + expect(result).not.toBeNull(); + const points = result.attributes['instanceStart'].array; + expect(points.length).toEqual(0); + }); +}); diff --git a/src/__tests__/preserving-parser.ts b/src/__tests__/preserving-parser.ts index b00a6a18..7171af75 100644 --- a/src/__tests__/preserving-parser.ts +++ b/src/__tests__/preserving-parser.ts @@ -3,7 +3,7 @@ import { test, expect } from 'vitest'; import { Parser } from '../gcode-parser'; test('all input should be preserved', () => { - const parser = new Parser(0); + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1`; const parsed = parser.parseGCode(gcode); expect(parsed).not.toBeNull(); @@ -12,7 +12,7 @@ test('all input should be preserved', () => { }); test('multiple lines should be preserved', () => { - const parser = new Parser(0); + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1\nG1 X10 Y10 E10`; const parsed = parser.parseGCode(gcode); expect(parsed).not.toBeNull(); @@ -21,7 +21,7 @@ test('multiple lines should be preserved', () => { }); test('comments should be preserved', () => { - const parser = new Parser(0); + const parser = new Parser(); const gcode = `G1 X0 Y0 Z1 E1; this is a comment`; const parsed = parser.parseGCode(gcode); expect(parsed).not.toBeNull(); diff --git a/src/__tests__/webgl-preview.ts b/src/__tests__/webgl-preview.ts index c89985fd..e06f0731 100644 --- a/src/__tests__/webgl-preview.ts +++ b/src/__tests__/webgl-preview.ts @@ -2,29 +2,9 @@ import { test, expect, vi, assert } from 'vitest'; -import { State, WebGLPreview } from '../webgl-preview'; +import { WebGLPreview } from '../webgl-preview'; import { GCodeCommand } from '../gcode-parser'; -test('in gcode x,y,z params should update the state', () => { - const mock = createMockPreview(); - mock.layers[0].commands.push(new GCodeCommand('', 'g0', { x: 1, y: 1, z: 1, e: 1 }, undefined)); - const layerIndex = 0; - WebGLPreview.prototype.renderLayer.call(mock, layerIndex); - expect(mock.state.x).toBe(1); - expect(mock.state.y).toBe(1); - expect(mock.state.z).toBe(1); -}); - -test('x,y,z params can go to 0', () => { - const mock = createMockPreview(); - mock.layers[0].commands.push(new GCodeCommand('', 'g0', { x: 0, y: 0, z: 0, e: 0 }, undefined)); - const layerIndex = 0; - WebGLPreview.prototype.renderLayer.call(mock, layerIndex); - expect(mock.state.x).toBe(0); - expect(mock.state.y).toBe(0); - expect(mock.state.z).toBe(0); -}); - // add a test for destroying the preview which should cancel the render loop. test('destroying the preview should dispose renderer and controls', async () => { const mock = createMockPreview(); @@ -86,7 +66,7 @@ test('cancelAnimation should cancel the render loop', async () => { function createMockPreview() { return { - state: State.initial, + // state: State.initial, minLayerIndex: 0, maxLayerIndex: Infinity, disposables: [ diff --git a/src/dev-gui.ts b/src/dev-gui.ts index 9f2b51c4..5ec5a899 100644 --- a/src/dev-gui.ts +++ b/src/dev-gui.ts @@ -109,17 +109,17 @@ class DevGUI { } private setupParserFolder(): void { - const parser = this.gui.addFolder('Parser'); - if (!this.openFolders.includes('Parser')) { + const parser = this.gui.addFolder('Job'); + if (!this.openFolders.includes('Job')) { parser.close(); } parser.onOpenClose(() => { this.saveOpenFolders(); }); - parser.add(this.watchedObject.parser, 'curZ').listen(); - parser.add(this.watchedObject.parser, 'maxZ').listen(); - parser.add(this.watchedObject.parser, 'tolerance').listen(); - parser.add(this.watchedObject.parser.layers, 'length').name('layers.count').listen(); + parser.add(this.watchedObject.job.state, 'x').listen(); + parser.add(this.watchedObject.job.state, 'y').listen(); + parser.add(this.watchedObject.job.state, 'z').listen(); + parser.add(this.watchedObject.job.paths, 'length').name('paths.count').listen(); parser.add(this.watchedObject.parser.lines, 'length').name('lines.count').listen(); } diff --git a/src/extrusion-geometry.ts b/src/extrusion-geometry.ts index bf41dcbd..545fdea1 100644 --- a/src/extrusion-geometry.ts +++ b/src/extrusion-geometry.ts @@ -102,7 +102,7 @@ class ExtrusionGeometry extends BufferGeometry { vertex.x = P.x + lineWidth * normal.x * 0.5; vertex.y = P.y + lineWidth * normal.y * 0.5; vertex.z = P.z + lineHeight * normal.z * 0.5; - vertices.push(vertex.x, vertex.y, vertex.z); + vertices.push(vertex.x, vertex.y, vertex.z - lineHeight * 0.5); } } diff --git a/src/gcode-parser.ts b/src/gcode-parser.ts index 700deb8e..354cddb2 100644 --- a/src/gcode-parser.ts +++ b/src/gcode-parser.ts @@ -56,10 +56,6 @@ type singleLetter = | 'Z'; type CommandParams = { [key in singleLetter]?: number }; -type MoveCommandParamName = 'x' | 'y' | 'z' | 'r' | 'e' | 'f' | 'i' | 'j'; -type MoveCommandParams = { - [key in MoveCommandParamName]?: number; -}; export class GCodeCommand { constructor( public src: string, @@ -69,73 +65,18 @@ export class GCodeCommand { ) {} } -export class MoveCommand extends GCodeCommand { - constructor( - src: string, - gcode: string, - public params: MoveCommandParams, - comment?: string - ) { - super(src, gcode, params, comment); - } -} - -export class SelectToolCommand extends GCodeCommand { - constructor( - src: string, - gcode: string, - comment?: string, - public toolIndex?: number - ) { - super(src, gcode, undefined, comment); - } -} - type Metadata = { thumbnails: Record }; -export class Layer { - constructor( - public layer: number, - public commands: GCodeCommand[], - public lineNumber: number, - public height: number = 0 - ) {} -} - export class Parser { - lines: string[] = []; - - /** - * @experimental GCode commands before extrusion starts. - */ - preamble = new Layer(-1, [], 0); // TODO: remove preamble and treat as a regular layer? Unsure of the benefit - layers: Layer[] = []; - curZ = 0; - maxZ = 0; metadata: Metadata = { thumbnails: {} }; - tolerance = 0; // The higher the tolerance, the fewer layers are created, so performance will improve. - - /** - * Create a new Parser instance. - * - * @param minLayerThreshold - If specified, the minimum layer height to be considered a new layer. If not specified, the default value is 0. - * @returns A new Parser instance. - */ - constructor(minLayerThreshold: number) { - this.tolerance = minLayerThreshold ?? this.tolerance; - } + lines: string[] = []; parseGCode(input: string | string[]): { - layers: Layer[]; metadata: Metadata; + commands: GCodeCommand[]; } { - const lines = Array.isArray(input) ? input : input.split('\n'); - - this.lines = this.lines.concat(lines); - - const commands = this.lines2commands(lines); - - this.groupIntoLayers(commands); + this.lines = Array.isArray(input) ? input : input.split('\n'); + const commands = this.lines2commands(this.lines); // merge thumbs const thumbs = this.parseMetadata(commands.filter((cmd) => cmd.comment)).thumbnails; @@ -143,11 +84,11 @@ export class Parser { this.metadata.thumbnails[key] = value; } - return { layers: this.layers, metadata: this.metadata }; + return { metadata: this.metadata, commands: commands }; } private lines2commands(lines: string[]) { - return lines.map((l) => this.parseCommand(l)) as GCodeCommand[]; + return lines.map((l) => this.parseCommand(l)); } parseCommand(line: string, keepComments = true): GCodeCommand | null { @@ -161,47 +102,9 @@ export class Parser { .slice(1) .map((s) => s.trim()); - const gcode = !parts.length ? '' : `${parts[0]?.toLowerCase()}${parts[1]}`; + const gcode = !parts.length ? '' : `${parts[0]?.toLowerCase()}${Number(parts[1])}`; const params = this.parseParams(parts.slice(2)); - switch (gcode) { - case 'g0': - case 'g00': - case 'g1': - case 'g01': - case 'g2': - case 'g02': - case 'g3': - case 'g03': - return new MoveCommand(line, gcode, params, comment); - case 't0': - return new SelectToolCommand(line, gcode, comment, 0); - case 't1': - return new SelectToolCommand(line, gcode, comment, 1); - case 't2': - return new SelectToolCommand(line, gcode, comment, 2); - case 't3': - return new SelectToolCommand(line, gcode, comment, 3); - case 't4': - return new SelectToolCommand(line, gcode, comment, 4); - case 't5': - return new SelectToolCommand(line, gcode, comment, 5); - case 't6': - return new SelectToolCommand(line, gcode, comment, 6); - case 't7': - return new SelectToolCommand(line, gcode, comment, 7); - default: - return new GCodeCommand(line, gcode, params, comment); - } - } - - // G0 & G1 - private parseMove(params: string[]): MoveCommandParams { - return params.reduce((acc: MoveCommandParams, cur: string) => { - const key = cur.charAt(0).toLowerCase(); - if (key == 'x' || key == 'y' || key == 'z' || key == 'e' || key == 'r' || key == 'f' || key == 'i' || key == 'j') - acc[key] = parseFloat(cur.slice(1)); - return acc; - }, {}); + return new GCodeCommand(line, gcode, params, comment); } private isAlpha(char: string | singleLetter): char is singleLetter { @@ -222,39 +125,6 @@ export class Parser { }, {}); } - private groupIntoLayers(commands: GCodeCommand[]): Layer[] { - for (let lineNumber = 0; lineNumber < commands.length; lineNumber++) { - const cmd = commands[lineNumber]; - - if (cmd instanceof MoveCommand) { - const params = cmd.params; - - // update current z? - if (params.z) { - this.curZ = params.z; // abs mode - } - - if ( - (params.e ?? 0) > 0 && // extruding? - (params.x != undefined || params.y != undefined) && // moving? - Math.abs(this.curZ - (this.maxZ || -Infinity)) > this.tolerance // new layer? - ) { - const layerHeight = Math.abs(this.curZ - this.maxZ); - this.maxZ = this.curZ; - this.layers.push(new Layer(this.layers.length, [], lineNumber, layerHeight)); - } - } - - this.maxLayer.commands.push(cmd); - } - - return this.layers; - } - - get maxLayer(): Layer { - return this.layers[this.layers.length - 1] ?? this.preamble; - } - parseMetadata(metadata: GCodeCommand[]): Metadata { const thumbnails: Record = {}; @@ -283,10 +153,3 @@ export class Parser { return { thumbnails }; } } - -// backwards compat; -// eslint-disable-next-line no-redeclare -export interface Parser { - parseGcode: typeof Parser.prototype.parseGCode; -} -Parser.prototype.parseGcode = Parser.prototype.parseGCode; diff --git a/src/interpreter.ts b/src/interpreter.ts new file mode 100644 index 00000000..80943929 --- /dev/null +++ b/src/interpreter.ts @@ -0,0 +1,186 @@ +import { Path, PathType } from './path'; +import { GCodeCommand } from './gcode-parser'; +import { Job } from './job'; + +export class Interpreter { + // eslint-disable-next-line no-unused-vars + [key: string]: (...args: unknown[]) => unknown; + execute(commands: GCodeCommand[], job = new Job()): Job { + job.resumeLastPath(); + commands.forEach((command) => { + if (command.gcode !== undefined) { + if (this[command.gcode] === undefined) { + return; + } + this[command.gcode](command, job); + } + }); + job.finishPath(); + + return job; + } + + g0(command: GCodeCommand, job: Job): void { + const { x, y, z, e } = command.params; + const { state } = job; + + let currentPath = job.inprogressPath; + const pathType = e > 0 ? PathType.Extrusion : PathType.Travel; + + if (currentPath === undefined || currentPath.travelType !== pathType) { + currentPath = this.breakPath(job, pathType); + } + + state.x = x ?? state.x; + state.y = y ?? state.y; + state.z = z ?? state.z; + + currentPath.addPoint(state.x, state.y, state.z); + } + + g1 = this.g0; + + g2(command: GCodeCommand, job: Job): void { + const { x, y, z, e } = command.params; + let { i, j, r } = command.params; + const { state } = job; + + const cw = command.gcode === 'g2'; + let currentPath = job.inprogressPath; + const pathType = e ? PathType.Extrusion : PathType.Travel; + + if (currentPath === undefined || currentPath.travelType !== pathType) { + currentPath = this.breakPath(job, pathType); + } + + if (r) { + // in r mode a minimum radius will be applied if the distance can otherwise not be bridged + const deltaX = x - state.x; // assume abs mode + const deltaY = y - state.y; + + // apply a minimal radius to bridge the distance + const minR = Math.sqrt(Math.pow(deltaX / 2, 2) + Math.pow(deltaY / 2, 2)); + r = Math.max(r, minR); + + const dSquared = Math.pow(deltaX, 2) + Math.pow(deltaY, 2); + const hSquared = Math.pow(r, 2) - dSquared / 4; + // if (dSquared == 0 || hSquared < 0) { + // return { position: { x: x, y: z, z: y }, points: [] }; //we'll abort the render and move te position to the new position. + // } + let hDivD = Math.sqrt(hSquared / dSquared); + + // Ref RRF DoArcMove for details + if ((cw && r < 0.0) || (!cw && r > 0.0)) { + hDivD = -hDivD; + } + i = deltaX / 2 + deltaY * hDivD; + j = deltaY / 2 - deltaX * hDivD; + // } else { + // //the radial point is an offset from the current position + // ///Need at least on point + // if (i == 0 && j == 0) { + // return { position: { x: x, y: y, z: z }, points: [] }; //we'll abort the render and move te position to the new position. + // } + } + + const wholeCircle = state.x == x && state.y == y; + const centerX = state.x + i; + const centerY = state.y + j; + + const arcRadius = Math.sqrt(i * i + j * j); + const arcCurrentAngle = Math.atan2(-j, -i); + const finalTheta = Math.atan2(y - centerY, x - centerX); + + let totalArc; + if (wholeCircle) { + totalArc = 2 * Math.PI; + } else { + totalArc = cw ? arcCurrentAngle - finalTheta : finalTheta - arcCurrentAngle; + if (totalArc < 0.0) { + totalArc += 2 * Math.PI; + } + } + let totalSegments = (arcRadius * totalArc) / 0.5; + if (state.units == 'in') { + totalSegments *= 25; + } + if (totalSegments < 1) { + totalSegments = 1; + } + let arcAngleIncrement = totalArc / totalSegments; + arcAngleIncrement *= cw ? -1 : 1; + + const zDist = state.z - (z || state.z); + const zStep = zDist / totalSegments; + + // get points for the arc + let px = state.x; + let py = state.y; + let pz = state.z; + // calculate segments + let currentAngle = arcCurrentAngle; + + for (let moveIdx = 0; moveIdx < totalSegments - 1; moveIdx++) { + currentAngle += arcAngleIncrement; + px = centerX + arcRadius * Math.cos(currentAngle); + py = centerY + arcRadius * Math.sin(currentAngle); + pz += zStep; + currentPath.addPoint(px, py, pz); + } + + state.x = x || state.x; + state.y = y || state.y; + state.z = z || state.z; + + currentPath.addPoint(state.x, state.y, state.z); + } + + g3 = this.g2; + + g20(command: GCodeCommand, job: Job): void { + job.state.units = 'in'; + } + + g21(command: GCodeCommand, job: Job): void { + job.state.units = 'mm'; + } + + g28(command: GCodeCommand, job: Job): void { + job.state.x = 0; + job.state.y = 0; + job.state.z = 0; + } + + t0(command: GCodeCommand, job: Job): void { + job.state.tool = 0; + } + t1(command: GCodeCommand, job: Job): void { + job.state.tool = 1; + } + t2(command: GCodeCommand, job: Job): void { + job.state.tool = 2; + } + t3(command: GCodeCommand, job: Job): void { + job.state.tool = 3; + } + t4(command: GCodeCommand, job: Job): void { + job.state.tool = 4; + } + t5(command: GCodeCommand, job: Job): void { + job.state.tool = 5; + } + t6(command: GCodeCommand, job: Job): void { + job.state.tool = 6; + } + t7(command: GCodeCommand, job: Job): void { + job.state.tool = 7; + } + + private breakPath(job: Job, newType: PathType): Path { + job.finishPath(); + const currentPath = new Path(newType, 0.6, 0.2, job.state.tool); + currentPath.addPoint(job.state.x, job.state.y, job.state.z); + job.inprogressPath = currentPath; + return currentPath; + } +} diff --git a/src/job.ts b/src/job.ts new file mode 100644 index 00000000..c198d807 --- /dev/null +++ b/src/job.ts @@ -0,0 +1,212 @@ +import { Path, PathType } from './path'; + +export class State { + x: number; + y: number; + z: number; + e: number; + tool: number; + units: 'mm' | 'in'; + + static get initial(): State { + const state = new State(); + Object.assign(state, { x: 0, y: 0, z: 0, e: 0, tool: 0, units: 'mm' }); + return state; + } +} + +export class Layer { + public layer: number; + public paths: Path[]; + public lineNumber: number; + public height: number = 0; + public z: number = 0; + constructor(layer: number, paths: Path[], lineNumber: number, height: number = 0, z: number = 0) { + this.layer = layer; + this.paths = paths; + this.lineNumber = lineNumber; + this.height = height; + this.z = z; + } +} + +export class Job { + paths: Path[] = []; + state: State; + private travelPaths: Path[] = []; + private extrusionPaths: Path[] = []; + private _layers: Layer[] = []; + private _toolPaths: Path[][] = []; + private indexers: Indexer[]; + inprogressPath: Path | undefined; + + constructor(opts: { state?: State; minLayerThreshold?: number } = {}) { + this.state = opts.state || State.initial; + this.indexers = [ + new TravelTypeIndexer({ travel: this.travelPaths, extrusion: this.extrusionPaths }), + new LayersIndexer(this._layers, opts.minLayerThreshold), + new ToolIndexer(this._toolPaths) + ]; + } + + get extrusions(): Path[] { + return this.extrusionPaths; + } + + get travels(): Path[] { + return this.travelPaths; + } + + get toolPaths(): Path[][] { + return this._toolPaths; + } + + get layers(): Layer[] { + return this._layers; + } + + addPath(path: Path): void { + this.paths.push(path); + this.indexPath(path); + } + + finishPath(): void { + if (this.inprogressPath === undefined) { + return; + } + if (this.inprogressPath.vertices.length > 0) { + this.addPath(this.inprogressPath); + this.inprogressPath = undefined; + } + } + + resumeLastPath(): void { + if (this.paths.length === 0) { + return; + } + this.inprogressPath = this.paths.pop(); + [this.extrusionPaths, this.travelPaths, this.layers[this.layers.length - 1]?.paths].forEach((indexer) => { + if (indexer === undefined || indexer.length === 0) { + return; + } + const travelIndex = indexer.indexOf(this.inprogressPath); + if (travelIndex > -1) { + indexer.splice(travelIndex, 1); + } + }); + } + + isPlanar(): boolean { + return this.layers.length > 0; + } + + private indexPath(path: Path): void { + this.indexers.forEach((indexer) => { + try { + indexer.sortIn(path); + } catch (e) { + if (e instanceof NonApplicableIndexer) { + if (e instanceof NonPlanarPathError) { + this._layers = []; + } + const i = this.indexers.indexOf(indexer); + this.indexers.splice(i, 1); + } else { + throw e; + } + } + }); + } +} + +class NonApplicableIndexer extends Error {} +export class Indexer { + protected indexes: unknown; + constructor(indexes: unknown) { + this.indexes = indexes; + } + sortIn(path: Path): void { + path; + throw new Error('Method not implemented.'); + } +} + +class TravelTypeIndexer extends Indexer { + protected declare indexes: Record; + constructor(indexes: Record) { + super(indexes); + } + + sortIn(path: Path): void { + if (path.travelType === PathType.Extrusion) { + this.indexes.extrusion.push(path); + } else { + this.indexes.travel.push(path); + } + } +} + +class NonPlanarPathError extends NonApplicableIndexer { + constructor() { + super("Non-planar paths can't be indexed by layer"); + } +} +export class LayersIndexer extends Indexer { + static readonly DEFAULT_TOLERANCE = 0.05; + protected declare indexes: Layer[]; + private tolerance: number; + constructor(indexes: Layer[], tolerance: number = LayersIndexer.DEFAULT_TOLERANCE) { + super(indexes); + this.tolerance = tolerance; + } + + sortIn(path: Path): void { + if ( + path.travelType === PathType.Extrusion && + path.vertices.some((_, i, arr) => i > 3 && i % 3 === 2 && Math.abs(arr[i] - arr[i - 3]) > this.tolerance) + ) { + throw new NonPlanarPathError(); + } + + if (this.indexes[this.indexes.length - 1] === undefined) { + this.createLayer(path.vertices[2]); + } + + if ( + path.travelType === PathType.Extrusion && + this.lastLayer().paths.some((p) => p.travelType === PathType.Extrusion) + ) { + if (path.vertices[2] - (this.lastLayer().z || 0) > this.tolerance) { + this.createLayer(path.vertices[2]); + } + } + this.lastLayer().paths.push(path); + } + + private lastLayer(): Layer { + return this.indexes[this.indexes.length - 1]; + } + + private createLayer(z: number): void { + const layerNumber = this.indexes.length; + const height = z - (this.lastLayer()?.z || 0); + this.indexes.push(new Layer(this.indexes.length, [], layerNumber, height, z)); + } +} + +class ToolIndexer extends Indexer { + protected declare indexes: Path[][]; + constructor(indexes: Path[][]) { + super(indexes); + } + sortIn(path: Path): void { + if (path.travelType === PathType.Extrusion) { + this.indexes; + this.indexes[path.tool] = this.indexes[path.tool] || []; + if (this.indexes[path.tool] === undefined) { + this.indexes[path.tool] = []; + } + this.indexes[path.tool].push(path); + } + } +} diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 00000000..fc9340b1 --- /dev/null +++ b/src/path.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-unused-vars */ +import { BufferGeometry, Vector3 } from 'three'; +import { ExtrusionGeometry } from './extrusion-geometry'; +import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; + +export enum PathType { + Travel = 'Travel', + Extrusion = 'Extrusion' +} + +export class Path { + public travelType: PathType; + public extrusionWidth: number; + public lineHeight: number; + public tool: number; + private _vertices: number[]; + + constructor(travelType: PathType, extrusionWidth = 0.6, lineHeight = 0.2, tool = 0) { + this.travelType = travelType; + this._vertices = []; + this.extrusionWidth = extrusionWidth; + this.lineHeight = lineHeight; + this.tool = tool; + } + + get vertices(): number[] { + return this._vertices; + } + + addPoint(x: number, y: number, z: number): void { + this._vertices.push(x, y, z); + } + + checkLineContinuity(x: number, y: number, z: number): boolean { + if (this._vertices.length < 3) { + return false; + } + + const lastX = this._vertices[this._vertices.length - 3]; + const lastY = this._vertices[this._vertices.length - 2]; + const lastZ = this._vertices[this._vertices.length - 1]; + + return x === lastX && y === lastY && z === lastZ; + } + + path(): Vector3[] { + const path: Vector3[] = []; + + for (let i = 0; i < this._vertices.length; i += 3) { + path.push(new Vector3(this._vertices[i], this._vertices[i + 1], this._vertices[i + 2])); + } + return path; + } + + geometry(opts: { extrusionWidthOverride?: number; lineHeightOverride?: number } = {}): BufferGeometry { + if (this._vertices.length < 3) { + return new BufferGeometry(); + } + + return new ExtrusionGeometry( + this.path(), + opts.extrusionWidthOverride ?? this.extrusionWidth, + opts.lineHeightOverride ?? this.lineHeight, + 4 + ); + } + + line(): LineSegmentsGeometry { + const lineVertices = []; + for (let i = 0; i < this._vertices.length - 3; i += 3) { + lineVertices.push(this._vertices[i], this._vertices[i + 1], this._vertices[i + 2]); + lineVertices.push(this._vertices[i + 3], this._vertices[i + 4], this._vertices[i + 5]); + } + + return new LineSegmentsGeometry().setPositions(lineVertices); + } + + hasVerticalMoves(): boolean { + return this.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]); + } +} diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 88519233..6fa2b598 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -1,24 +1,31 @@ -import { Parser, MoveCommand, Layer, SelectToolCommand } from './gcode-parser'; +import { Parser } from './gcode-parser'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; -import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; + import { BuildVolume } from './build-volume'; import { type Disposable } from './helpers/three-utils'; import Stats from 'three/examples/jsm/libs/stats.module.js'; import { DevGUI, DevModeOptions } from './dev-gui'; +import { Interpreter } from './interpreter'; +import { Job } from './job'; +import { Path } from './path'; import { AmbientLight, BatchedMesh, + BufferGeometry, Color, ColorRepresentation, Euler, Fog, Group, + Material, MeshLambertMaterial, PerspectiveCamera, + Plane, PointLight, REVISION, Scene, @@ -26,34 +33,6 @@ import { WebGLRenderer } from 'three'; -import { ExtrusionGeometry } from './extrusion-geometry'; - -type RenderLayer = { extrusion: number[]; travel: number[]; z: number; height: number }; -type GVector3 = { - x: number; - y: number; - z: number; -}; -type Arc = GVector3 & { r: number; i: number; j: number }; - -type Point = GVector3; -export class State { - x: number; - y: number; - z: number; - r: number; - e: number; - i: number; - j: number; - t: number; // tool index - // feedrate? - static get initial(): State { - const state = new State(); - Object.assign(state, { x: 0, y: 0, z: 0, r: 0, e: 0, i: 0, j: 0, t: 0 }); - return state; - } -} - export type GCodePreviewOptions = { buildVolume?: BuildVolume; backgroundColor?: ColorRepresentation; @@ -75,7 +54,6 @@ export type GCodePreviewOptions = { toolColors?: Record; disableGradient?: boolean; extrusionWidth?: number; - /** @experimental */ renderTubes?: boolean; /** * @deprecated Please see the demo how to implement drag and drop. @@ -89,15 +67,8 @@ export type GCodePreviewOptions = { devMode?: boolean | DevModeOptions; }; -const target = { - h: 0, - s: 0, - l: 0 -}; - export class WebGLPreview { - minLayerThreshold = 0.05; - parser: Parser; + minLayerThreshold: number; /** * @deprecated Please use the `canvas` param instead. */ @@ -110,12 +81,12 @@ export class WebGLPreview { renderExtrusion = true; renderTravel = false; renderTubes = false; - extrusionWidth = 0.6; + extrusionWidth?: number; lineWidth?: number; lineHeight?: number; - startLayer?: number; - endLayer?: number; - singleLayerMode = false; + _startLayer?: number; + _endLayer?: number; + _singleLayerMode = false; buildVolume?: BuildVolume; initialCameraPosition = [-100, 400, 450]; /** @@ -126,9 +97,9 @@ export class WebGLPreview { nonTravelmoves: string[] = []; disableGradient = false; - // gcode processing state - private state: State = State.initial; - private beyondFirstMove = false; // TODO: move to state + private job: Job; + interpreter = new Interpreter(); + parser = new Parser(); // rendering private group?: Group; @@ -136,8 +107,10 @@ export class WebGLPreview { static readonly defaultExtrusionColor = new Color('hotpink'); private _extrusionColor: Color | Color[] = WebGLPreview.defaultExtrusionColor; private animationFrameId?: number; - private renderLayerIndex = 0; - private _geometries: Record = {}; + private renderPathIndex?: number; + private minPlane = new Plane(new Vector3(0, 1, 0), 0.6); + private maxPlane = new Plane(new Vector3(0, -1, 0), 0.1); + private clippingPlanes: Plane[] = []; // colors private _backgroundColor = new Color(0xe0e0e0); @@ -156,7 +129,7 @@ export class WebGLPreview { constructor(opts: GCodePreviewOptions) { this.minLayerThreshold = opts.minLayerThreshold ?? this.minLayerThreshold; - this.parser = new Parser(this.minLayerThreshold); + this.job = new Job({ minLayerThreshold: this.minLayerThreshold }); this.scene = new Scene(); this.scene.background = this._backgroundColor; if (opts.backgroundColor !== undefined) { @@ -174,7 +147,7 @@ export class WebGLPreview { this.renderTravel = opts.renderTravel ?? this.renderTravel; this.nonTravelmoves = opts.nonTravelMoves ?? this.nonTravelmoves; this.renderTubes = opts.renderTubes ?? this.renderTubes; - this.extrusionWidth = opts.extrusionWidth ?? this.extrusionWidth; + this.extrusionWidth = opts.extrusionWidth; this.devMode = opts.devMode ?? this.devMode; this.stats = this.devMode ? new Stats() : undefined; @@ -227,6 +200,7 @@ export class WebGLPreview { }); } + this.renderer.localClippingEnabled = true; this.camera = new PerspectiveCamera(25, this.canvas.offsetWidth / this.canvas.offsetHeight, 10, 5000); this.camera.position.fromArray(this.initialCameraPosition); const fogFar = (this.camera as PerspectiveCamera).far; @@ -259,18 +233,6 @@ export class WebGLPreview { this._extrusionColor = new Color(value); } - // get tool color based on current state - get currentToolColor(): Color { - if (this._extrusionColor === undefined) { - return WebGLPreview.defaultExtrusionColor; - } - if (this._extrusionColor instanceof Color) { - return this._extrusionColor; - } - - return this._extrusionColor[this.state.t] ?? WebGLPreview.defaultExtrusionColor; - } - get backgroundColor(): Color { return this._backgroundColor; } @@ -301,21 +263,53 @@ export class WebGLPreview { this._lastSegmentColor = value !== undefined ? new Color(value) : undefined; } - /** - * @internal Do not use externally. - */ - get layers(): Layer[] { - return [this.parser.preamble].concat(this.parser.layers.concat()); + get countLayers(): number { + return this.job.layers.length; } - // convert from 1-based to 0-based - get maxLayerIndex(): number { - return (this.endLayer ?? this.layers.length) - 1; + get startLayer(): number { + return this._startLayer; + } + set startLayer(value: number) { + if (this.countLayers > 1 && value > 0) { + this._startLayer = value; + if (value <= this.countLayers) { + console.log(value); + const layer = this.job.layers[value - 1]; + this.minPlane.constant = -this.minPlane.normal.y * layer.z; + this.clippingPlanes = [this.minPlane, this.maxPlane]; + } else { + this.minPlane.constant = 0; + this.clippingPlanes = []; + } + } } - // convert from 1-based to 0-based - get minLayerIndex(): number { - return this.singleLayerMode ? this.maxLayerIndex : (this.startLayer ?? 0) - 1; + get endLayer(): number { + return this._endLayer; + } + set endLayer(value: number) { + if (this.countLayers > 1 && value > 0) { + this._endLayer = value; + if (this._singleLayerMode === true) { + this.startLayer = this._endLayer; + } + if (value <= this.countLayers) { + const layer = this.job.layers[value - 1]; + this.maxPlane.constant = -this.maxPlane.normal.y * layer.z; + this.clippingPlanes = [this.minPlane, this.maxPlane]; + } else { + this.maxPlane.constant = 0; + this.clippingPlanes = []; + } + } + } + + set singleLayerMode(value: boolean) { + this._singleLayerMode = value; + if (value) { + this.startLayer = this.endLayer - 1; + } } /** @internal */ @@ -327,7 +321,8 @@ export class WebGLPreview { } processGCode(gcode: string | string[]): void { - this.parser.parseGCode(gcode); + const { commands } = this.parser.parseGCode(gcode); + this.interpreter.execute(commands, this.job); this.render(); } @@ -372,14 +367,9 @@ export class WebGLPreview { render(): void { const startRender = performance.now(); this.group = this.createGroup('allLayers'); - this.state = State.initial; this.initScene(); - for (let index = 0; index < this.layers.length; index++) { - this.renderLayer(index); - } - - this.batchGeometries(); + this.renderPaths(); this.scene.add(this.group); this.renderer.render(this.scene, this.camera); @@ -388,20 +378,25 @@ export class WebGLPreview { // create a new render method to use an animation loop to render the layers incrementally /** @experimental */ - async renderAnimated(layerCount = 1): Promise { + async renderAnimated(pathCount = 1): Promise { this.initScene(); - this.renderLayerIndex = 0; - return this.renderFrameLoop(layerCount > 0 ? layerCount : 1); + this.renderPathIndex = 0; + + if (this.renderPathIndex >= this.job.paths.length - 1) { + this.render(); + } else { + return this.renderFrameLoop(pathCount > 0 ? Math.min(pathCount, this.job.paths.length) : 1); + } } - private renderFrameLoop(layerCount: number): Promise { + private renderFrameLoop(pathCount: number): Promise { return new Promise((resolve) => { const loop = () => { - if (this.renderLayerIndex > this.layers.length - 1) { + if (this.renderPathIndex >= this.job.paths.length - 1) { resolve(); } else { - this.renderFrame(layerCount); + this.renderFrame(pathCount); requestAnimationFrame(loop); } }; @@ -409,151 +404,27 @@ export class WebGLPreview { }); } - private renderFrame(layerCount: number): void { - this.group = this.createGroup('layer' + this.renderLayerIndex); - - for (let l = 0; l < layerCount && this.renderLayerIndex + l < this.layers.length; l++) { - this.renderLayer(this.renderLayerIndex); - this.renderLayerIndex++; - } - - this.batchGeometries(); - + private renderFrame(pathCount: number): void { + this.group = this.createGroup('parts' + this.renderPathIndex); + const endPathNumber = Math.min(this.renderPathIndex + pathCount, this.job.paths.length - 1); + this.renderPaths(endPathNumber); + this.renderPathIndex = endPathNumber; this.scene.add(this.group); } - /** - * @internal - */ - renderLayer(index: number): void { - if (index > this.maxLayerIndex) return; - const l = this.layers[index]; - - const currentLayer: RenderLayer = { - extrusion: [], - travel: [], - z: this.state.z, - height: l.height - }; - - for (const cmd of l.commands) { - if (cmd.gcode == 'g20') { - this.setInches(); - continue; - } - - if (cmd.gcode.startsWith('t')) { - // flush render queue - this.doRenderExtrusion(currentLayer, index); - currentLayer.extrusion = []; - - const tool = cmd as SelectToolCommand; - this.state.t = tool.toolIndex; - continue; - } - - if (['g0', 'g00', 'g1', 'g01', 'g2', 'g02', 'g3', 'g03'].indexOf(cmd.gcode) > -1) { - const g = cmd as MoveCommand; - const next: State = { - x: g.params.x ?? this.state.x, - y: g.params.y ?? this.state.y, - z: g.params.z ?? this.state.z, - r: g.params.r ?? this.state.r, - e: g.params.e ?? this.state.e, - i: g.params.i ?? this.state.i, - j: g.params.j ?? this.state.j, - t: this.state.t - }; - - if (index >= this.minLayerIndex) { - const extrude = (g.params.e ?? 0) > 0 || this.nonTravelmoves.indexOf(cmd.gcode) > -1; - const moving = next.x != this.state.x || next.y != this.state.y || next.z != this.state.z; - if (moving) { - if ((extrude && this.renderExtrusion) || (!extrude && this.renderTravel)) { - if (cmd.gcode == 'g2' || cmd.gcode == 'g3' || cmd.gcode == 'g02' || cmd.gcode == 'g03') { - this.addArcSegment(currentLayer, this.state, next, extrude, cmd.gcode == 'g2' || cmd.gcode == 'g02'); - } else { - this.addLineSegment(currentLayer, this.state, next, extrude); - } - } - } - } - - // update this.state - this.state.x = next.x; - this.state.y = next.y; - this.state.z = next.z; - // if (next.e) state.e = next.e; // where not really tracking e as distance (yet) but we only check if some commands are extruding (positive e) - if (!this.beyondFirstMove) this.beyondFirstMove = true; - } - } - - this.doRenderExtrusion(currentLayer, index); - } - - /** @internal */ - doRenderExtrusion(layer: RenderLayer, index: number): void { - if (this.renderExtrusion) { - let extrusionColor = this.currentToolColor; - - if (!this.singleLayerMode && !this.renderTubes && !this.disableGradient) { - const brightness = 0.1 + (0.7 * index) / this.layers.length; - - extrusionColor.getHSL(target); - extrusionColor = new Color().setHSL(target.h, target.s, brightness); - } - - if (index == this.layers.length - 1) { - const layerColor = this._topLayerColor ?? extrusionColor; - const lastSegmentColor = this._lastSegmentColor ?? layerColor; - - const endPoint = layer.extrusion.splice(-3); - const preendPoint = layer.extrusion.splice(-3); - if (this.renderTubes) { - this.addTubeLine(layer.extrusion, layerColor.getHex(), layer.height); - this.addTubeLine([...preendPoint, ...endPoint], lastSegmentColor.getHex(), layer.height); - } else { - this.addLine(layer.extrusion, layerColor.getHex()); - this.addLine([...preendPoint, ...endPoint], lastSegmentColor.getHex()); - } - } else { - if (this.renderTubes) { - this.addTubeLine(layer.extrusion, extrusionColor.getHex(), layer.height); - } else { - this.addLine(layer.extrusion, extrusionColor.getHex()); - } - } - } - - if (this.renderTravel) { - this.addLine(layer.travel, this._travelColor.getHex()); - } - } - - setInches(): void { - if (this.beyondFirstMove) { - console.warn('Switching units after movement is already made is discouraged and is not supported.'); - return; - } - this.inches = true; - } - // reset parser & processing state clear(): void { this.resetState(); - this.parser = new Parser(this.minLayerThreshold); + this.parser = new Parser(); + this.job = new Job({ minLayerThreshold: this.minLayerThreshold }); } // reset processing state private resetState(): void { this.startLayer = 1; this.endLayer = Infinity; - this.singleLayerMode = false; - - this.beyondFirstMove = false; - this.state = State.initial; + this._singleLayerMode = false; this.devGui?.reset(); - this._geometries = {}; } resize(): void { @@ -564,158 +435,6 @@ export class WebGLPreview { this.renderer.setSize(w, h, false); } - /** @internal */ - addLineSegment(layer: RenderLayer, p1: Point, p2: Point, extrude: boolean): void { - const line = extrude ? layer.extrusion : layer.travel; - line.push(p1.x, p1.y, p1.z, p2.x, p2.y, p2.z); - } - - /** @internal */ - addArcSegment(layer: RenderLayer, p1: Point, p2: Arc, extrude: boolean, cw: boolean): void { - const line = extrude ? layer.extrusion : layer.travel; - - const currX = p1.x, - currY = p1.y, - currZ = p1.z, - x = p2.x, - y = p2.y, - z = p2.z; - let r = p2.r; - - let i = p2.i, - j = p2.j; - - if (r) { - // in r mode a minimum radius will be applied if the distance can otherwise not be bridged - const deltaX = x - currX; // assume abs mode - const deltaY = y - currY; - - // apply a minimal radius to bridge the distance - const minR = Math.sqrt(Math.pow(deltaX / 2, 2) + Math.pow(deltaY / 2, 2)); - r = Math.max(r, minR); - - const dSquared = Math.pow(deltaX, 2) + Math.pow(deltaY, 2); - const hSquared = Math.pow(r, 2) - dSquared / 4; - // if (dSquared == 0 || hSquared < 0) { - // return { position: { x: x, y: z, z: y }, points: [] }; //we'll abort the render and move te position to the new position. - // } - let hDivD = Math.sqrt(hSquared / dSquared); - - // Ref RRF DoArcMove for details - if ((cw && r < 0.0) || (!cw && r > 0.0)) { - hDivD = -hDivD; - } - i = deltaX / 2 + deltaY * hDivD; - j = deltaY / 2 - deltaX * hDivD; - // } else { - // //the radial point is an offset from the current position - // ///Need at least on point - // if (i == 0 && j == 0) { - // return { position: { x: x, y: y, z: z }, points: [] }; //we'll abort the render and move te position to the new position. - // } - } - - const wholeCircle = currX == x && currY == y; - const centerX = currX + i; - const centerY = currY + j; - - const arcRadius = Math.sqrt(i * i + j * j); - const arcCurrentAngle = Math.atan2(-j, -i); - const finalTheta = Math.atan2(y - centerY, x - centerX); - - let totalArc; - if (wholeCircle) { - totalArc = 2 * Math.PI; - } else { - totalArc = cw ? arcCurrentAngle - finalTheta : finalTheta - arcCurrentAngle; - if (totalArc < 0.0) { - totalArc += 2 * Math.PI; - } - } - let totalSegments = (arcRadius * totalArc) / 1.8; - if (this.inches) { - totalSegments *= 25; - } - if (totalSegments < 1) { - totalSegments = 1; - } - let arcAngleIncrement = totalArc / totalSegments; - arcAngleIncrement *= cw ? -1 : 1; - - const points = []; - - points.push({ x: currX, y: currY, z: currZ }); - - const zDist = currZ - z; - const zStep = zDist / totalSegments; - - // get points for the arc - let px = currX; - let py = currY; - let pz = currZ; - // calculate segments - let currentAngle = arcCurrentAngle; - - for (let moveIdx = 0; moveIdx < totalSegments - 1; moveIdx++) { - currentAngle += arcAngleIncrement; - px = centerX + arcRadius * Math.cos(currentAngle); - py = centerY + arcRadius * Math.sin(currentAngle); - pz += zStep; - points.push({ x: px, y: py, z: pz }); - } - - points.push({ x: p2.x, y: p2.y, z: p2.z }); - - for (let idx = 0; idx < points.length - 1; idx++) { - line.push(points[idx].x, points[idx].y, points[idx].z, points[idx + 1].x, points[idx + 1].y, points[idx + 1].z); - } - } - - /** @internal */ - addLine(vertices: number[], color: number): void { - const geometry = new LineSegmentsGeometry(); - this.disposables.push(geometry); - - const matLine = new LineMaterial({ - color: color, - linewidth: this.lineWidth - }); - this.disposables.push(matLine); - - geometry.setPositions(vertices); - const line = new LineSegments2(geometry, matLine); - - this.group?.add(line); - } - - /** @internal */ - addTubeLine(vertices: number[], color: number, layerHeight = 0.2): void { - let curvePoints: Vector3[] = []; - const extrusionPaths: Vector3[][] = []; - - // Merging into one curve for performance - for (let i = 0; i < vertices.length; i += 6) { - const v = vertices.slice(i, i + 9); - const startPoint = new Vector3(v[0], v[1], v[2]); - const endPoint = new Vector3(v[3], v[4], v[5]); - const nextPoint = new Vector3(v[6], v[7], v[8]); - - curvePoints.push(startPoint); - - if (!endPoint.equals(nextPoint)) { - curvePoints.push(endPoint); - extrusionPaths.push(curvePoints); - curvePoints = []; - } - } - - extrusionPaths.forEach((extrusionPath) => { - const geometry = new ExtrusionGeometry(extrusionPath, this.extrusionWidth, this.lineHeight || layerHeight, 4); - this._geometries[color] ||= []; - this._geometries[color].push(geometry); - }); - } - dispose(): void { this.disposables.forEach((d) => d.dispose()); this.disposables = []; @@ -760,29 +479,85 @@ export class WebGLPreview { }); } - private batchGeometries() { - if (this._geometries) { - for (const color in this._geometries) { - const batchedMesh = this.createBatchMesh(parseInt(color)); - while (this._geometries[color].length > 0) { - const geometry = this._geometries[color].pop(); - const geometryId = batchedMesh.addGeometry(geometry); - batchedMesh.addInstance(geometryId); + private renderPaths(endPathNumber: number = Infinity): void { + console.log('rendering paths'); + if (this.renderTravel) { + this.renderPathsAsLines(this.job.travels.slice(this.renderPathIndex, endPathNumber), this._travelColor); + } + + if (this.renderExtrusion) { + this.job.toolPaths.forEach((toolPaths, index) => { + const color = Array.isArray(this._extrusionColor) ? this._extrusionColor[index] : this._extrusionColor; + if (this.renderTubes) { + this.renderPathsAsTubes(toolPaths.slice(this.renderPathIndex, endPathNumber), color); + } else { + this.renderPathsAsLines(toolPaths.slice(this.renderPathIndex, endPathNumber), color); } - } + }); } } - private createBatchMesh(color: number): BatchedMesh { - const geometries = this._geometries[color]; - const material = new MeshLambertMaterial({ color: color, wireframe: this._wireframe }); + private renderPathsAsLines(paths: Path[], color: Color): void { + console.log(this.clippingPlanes); + const material = new LineMaterial({ + color: Number(color.getHex()), + linewidth: this.lineWidth, + clippingPlanes: this.clippingPlanes + }); + + const lineVertices: number[] = []; + paths.forEach((path) => { + for (let i = 0; i < path.vertices.length - 3; i += 3) { + lineVertices.push(path.vertices[i], path.vertices[i + 1], path.vertices[i + 2]); + lineVertices.push(path.vertices[i + 3], path.vertices[i + 4], path.vertices[i + 5]); + } + }); + + const geometry = new LineSegmentsGeometry().setPositions(lineVertices); + const line = new LineSegments2(geometry, material); + this.disposables.push(material); + this.disposables.push(geometry); + this.group?.add(line); + } + + private renderPathsAsTubes(paths: Path[], color: Color): void { + const colorNumber = Number(color.getHex()); + const geometries: BufferGeometry[] = []; + + const material = new MeshLambertMaterial({ + color: colorNumber, + wireframe: this._wireframe, + clippingPlanes: this.clippingPlanes + }); + + paths.forEach((path) => { + const geometry = path.geometry({ + extrusionWidthOverride: this.extrusionWidth, + lineHeightOverride: this.lineHeight + }); + this.disposables.push(geometry); + geometries.push(geometry); + }); + const batchedMesh = this.createBatchMesh(geometries, material); + this.disposables.push(material); + // this.disposables.push(batchedMesh); + + this.group?.add(batchedMesh); + } + + private createBatchMesh(geometries: BufferGeometry[], material: Material): BatchedMesh { const maxVertexCount = geometries.reduce((acc, geometry) => geometry.attributes.position.count * 3 + acc, 0); const batchedMesh = new BatchedMesh(geometries.length, maxVertexCount, undefined, material); this.disposables.push(batchedMesh); - this.group?.add(batchedMesh); + + geometries.forEach((geometry) => { + const geometryId = batchedMesh.addGeometry(geometry); + batchedMesh.addInstance(geometryId); + }); + return batchedMesh; }