diff --git a/demo/js/app.js b/demo/js/app.js index 5972fe7e..753b5778 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -54,7 +54,6 @@ export const app = (window.app = createApp({ const updateUI = async () => { const { parser, - layers, extrusionColor, topLayerColor, lastSegmentColor, @@ -66,16 +65,17 @@ export const app = (window.app = createApp({ renderExtrusion, lineWidth, renderTubes, - extrusionWidth + extrusionWidth, + job } = preview; const { thumbnails } = parser.metadata; thumbnail.value = thumbnails['220x124']?.src; - layerCount.value = layers.length; + layerCount.value = job.layers()?.length; const colors = extrusionColor instanceof Array ? extrusionColor : [extrusionColor]; const currentSettings = { - maxLayer: layers.length, - endLayer: layers.length, + maxLayer: job.layers()?.length, + endLayer: job.layers()?.length, singleLayerMode, renderTravel, travelColor: '#' + travelColor.getHexString(), @@ -94,7 +94,7 @@ export const app = (window.app = createApp({ }; Object.assign(settings.value, currentSettings); - preview.endLayer = layers.length; + preview.endLayer = job.layers()?.length; }; const loadGCodeFromServer = async (filename) => { @@ -113,9 +113,16 @@ export const app = (window.app = createApp({ const prevDevMode = preview.devMode; preview.clear(); preview.devMode = prevDevMode; + if (loadProgressive) { - preview.parser.parseGCode(gcode); - // await preview.renderAnimated(Math.ceil(preview.layers.length / 60)); + const { commands } = preview.parser.parseGCode(gcode); + preview.interpreter.execute(commands, preview.job); + if (preview.job.layers() === null) { + console.warn('Job is not planar'); + preview.render(); + return; + } + await preview.renderAnimated(Math.ceil(preview.job.layers().length / 60)); } else { preview.processGCode(gcode); } @@ -205,7 +212,7 @@ export const app = (window.app = createApp({ preview.lastSegmentColor = settings.value.highlightLastSegment ? settings.value.lastSegmentColor : undefined; debounce(() => { - preview.renderAnimated(Math.ceil(preview.layers.length / 60)); + preview.renderAnimated(Math.ceil(preview.job.layers().length / 60)); }); }); }); 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..49b58cf6 --- /dev/null +++ b/src/__tests__/interpreter.ts @@ -0,0 +1,286 @@ +import { test, expect } from 'vitest'; +import { GCodeCommand } from '../gcode-parser'; +import { Interpreter } from '../interpreter'; +import { Job } from '../job'; +import { PathType } from '../path'; + +test('.execute 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('.execute 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('.execute 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('.execute 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('.G0 moves the state to the new position', () => { + const command = new GCodeCommand('G0 X1 Y2 Z3', 'g0', { x: 1, y: 2, z: 3 }); + const interpreter = new Interpreter(); + + const job = interpreter.execute([command]); + + expect(job.state.x).toEqual(1); + expect(job.state.y).toEqual(2); + expect(job.state.z).toEqual(3); +}); + +test('.G0 starts a path if the job has none', () => { + const command = new GCodeCommand('G0 X1 Y2', 'g0', { x: 1, y: 2 }); + const interpreter = new Interpreter(); + + const job = interpreter.execute([command]); + + expect(job.paths.length).toEqual(1); + expect(job.paths[0].vertices.length).toEqual(6); + expect(job.paths[0].vertices[0]).toEqual(0); + expect(job.paths[0].vertices[1]).toEqual(0); + expect(job.paths[0].vertices[2]).toEqual(0); + expect(job.paths[0].vertices[3]).toEqual(1); + expect(job.paths[0].vertices[4]).toEqual(2); + expect(job.paths[0].vertices[5]).toEqual(0); +}); + +test('.G0 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.execute([command], job); + + expect(job.paths.length).toEqual(1); + expect(job.paths[0].vertices.length).toEqual(6); + expect(job.paths[0].vertices[0]).toEqual(3); + expect(job.paths[0].vertices[1]).toEqual(4); + expect(job.paths[0].vertices[2]).toEqual(0); + expect(job.paths[0].tool).toEqual(5); +}); + +test('.G0 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.execute([command1], job); + + interpreter.G0(command2, job); + + expect(job.paths.length).toEqual(1); + expect(job.paths[0].vertices.length).toEqual(9); + expect(job.paths[0].vertices[6]).toEqual(3); + expect(job.paths[0].vertices[7]).toEqual(4); + expect(job.paths[0].vertices[8]).toEqual(5); +}); + +test(".G0 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(1); + expect(job.paths[0].travelType).toEqual(PathType.Travel); +}); + +test(".G0 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(1); + expect(job.paths[0].travelType).toEqual('Extrusion'); +}); + +test('.G0 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(1); + expect(job.paths[0].travelType).toEqual('Travel'); +}); + +test('.G0 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(2); + expect(job.paths[0].travelType).toEqual('Travel'); + expect(job.paths[1].travelType).toEqual('Extrusion'); +}); + +test('.G0 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(2); + expect(job.paths[0].travelType).toEqual('Extrusion'); + expect(job.paths[1].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('.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..8d1d0538 --- /dev/null +++ b/src/__tests__/job.ts @@ -0,0 +1,159 @@ +import { test, expect, describe } from 'vitest'; +import { Job, State } 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(null); + }); + + 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].length).toEqual(2); + }); + + test('travel paths moving z create a new 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, 1]); + + const layers = job.layers(); + + expect(layers).not.toBeNull(); + expect(layers).toBeInstanceOf(Array); + expect(layers?.length).toEqual(2); + expect(layers?.[0].length).toEqual(1); + expect(layers?.[1].length).toEqual(1); + }); + + 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(2); + expect(layers?.[0].length).toEqual(1); + expect(layers?.[1].length).toEqual(3); + }); + + 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].length).toEqual(1); + expect(layers?.[1].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); + }); + }); +}); + +function append_path(job, travelType, vertices) { + const path = new Path(travelType, 0.6, 0.2, job.state.tool); + path.vertices = vertices; + job.paths.push(path); +} diff --git a/src/__tests__/path.ts b/src/__tests__/path.ts new file mode 100644 index 00000000..af040e0b --- /dev/null +++ b/src/__tests__/path.ts @@ -0,0 +1,129 @@ +import { test, expect } 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 }); +}); + +test('.geometry returns an ExtrusionGeometry from the path', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.vertices = [0, 0, 0, 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('.geometry returns an ExtrusionGeometry with the path extrusion width', () => { + const path = new Path(PathType.Travel, 9, undefined, undefined); + + path.vertices = [0, 0, 0, 1, 2, 3]; + + const result = path.geometry() as ExtrusionGeometry; + + expect(result.parameters.lineWidth).toEqual(9); +}); + +test('.geometry returns an ExtrusionGeometry with the path line height', () => { + const path = new Path(PathType.Travel, undefined, 5, undefined); + + path.vertices = [0, 0, 0, 1, 2, 3]; + + const result = path.geometry() as ExtrusionGeometry; + + expect(result.parameters.lineHeight).toEqual(5); +}); + +test('.geometry 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); +}); + +test('.line returns a BufferGeometry from the path', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); + + path.vertices = [0, 0, 0, 1, 2, 3]; + + const result = path.line(); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(BufferGeometry); + expect(result.getAttribute('position').count).toEqual(2); +}); + +test('.line 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(); + expect(result).toBeInstanceOf(BufferGeometry); + expect(result.getAttribute('position').count).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/gcode-parser.ts b/src/gcode-parser.ts index 700deb8e..c3fbccd2 100644 --- a/src/gcode-parser.ts +++ b/src/gcode-parser.ts @@ -56,86 +56,82 @@ 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 enum Code { + G0 = 'G0', + G1 = 'G1', + G2 = 'G2', + G3 = 'G3', + G20 = 'G20', + T0 = 'T0', + T1 = 'T1', + T2 = 'T2', + T3 = 'T3', + T4 = 'T4', + T5 = 'T5', + T6 = 'T6', + T7 = 'T7' +} export class GCodeCommand { + public code?: Code; constructor( public src: string, public gcode: string, public params: CommandParams, public comment?: string - ) {} -} - -export class MoveCommand extends GCodeCommand { - constructor( - src: string, - gcode: string, - public params: MoveCommandParams, - comment?: string ) { - super(src, gcode, params, comment); + this.code = this.match(gcode); } -} -export class SelectToolCommand extends GCodeCommand { - constructor( - src: string, - gcode: string, - comment?: string, - public toolIndex?: number - ) { - super(src, gcode, undefined, comment); + match(gcode: string): Code { + switch (gcode) { + case 'g0': + case 'g00': + return Code.G0; + case 'g1': + case 'g01': + return Code.G1; + case 'g2': + case 'g02': + return Code.G2; + case 'g3': + case 'g03': + return Code.G3; + case 'g20': + return Code.G20; + case 't0': + return Code.T0; + case 't1': + return Code.T1; + case 't2': + return Code.T2; + case 't3': + return Code.T3; + case 't4': + return Code.T4; + case 't5': + return Code.T5; + case 't6': + return Code.T6; + case 't7': + return Code.T7; + default: + return undefined; + } } } 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 +139,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 { @@ -163,45 +159,7 @@ export class Parser { const gcode = !parts.length ? '' : `${parts[0]?.toLowerCase()}${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 +180,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 +208,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..65b09e15 --- /dev/null +++ b/src/interpreter.ts @@ -0,0 +1,168 @@ +import { Path, PathType } from './path'; +import { Code, GCodeCommand } from './gcode-parser'; +import { Job } from './job'; + +export class Interpreter { + execute(commands: GCodeCommand[], job = new Job()): Job { + commands.forEach((command) => { + if (command.code !== undefined) { + this[command.code](command, job); + } + }); + + return job; + } + + G0(command: GCodeCommand, job: Job): void { + const { x, y, z, e } = command.params; + const { state } = job; + + let lastPath = job.paths[job.paths.length - 1]; + const pathType = e > 0 ? PathType.Extrusion : PathType.Travel; + + if (lastPath === undefined || lastPath.travelType !== pathType) { + lastPath = this.breakPath(job, pathType); + } + + state.x = x ?? state.x; + state.y = y ?? state.y; + state.z = z ?? state.z; + + lastPath.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.code === Code.G2; + let lastPath = job.paths[job.paths.length - 1]; + const pathType = e ? PathType.Extrusion : PathType.Travel; + + if (lastPath === undefined || lastPath.travelType !== pathType) { + lastPath = 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; + lastPath.addPoint(px, py, pz); + } + + state.x = x || state.x; + state.y = y || state.y; + state.z = z || state.z; + + lastPath.addPoint(state.x, state.y, state.z); + } + + G3 = this.G2; + + G20(command: GCodeCommand, job: Job): void { + job.state.units = 'in'; + } + + 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 { + const lastPath = new Path(newType, 0.6, 0.2, job.state.tool); + job.paths.push(lastPath); + lastPath.addPoint(job.state.x, job.state.y, job.state.z); + return lastPath; + } +} diff --git a/src/job.ts b/src/job.ts new file mode 100644 index 00000000..47f332ed --- /dev/null +++ b/src/job.ts @@ -0,0 +1,71 @@ +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 Job { + paths: Path[]; + state: State; + + constructor(state?: State) { + this.paths = []; + this.state = state || State.initial; + } + + isPlanar(): boolean { + return ( + this.paths.find( + (path) => + path.travelType === PathType.Extrusion && path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]) + ) === undefined + ); + } + + layers(): Path[][] | null { + if (!this.isPlanar()) { + return null; + } + + const layers: Path[][] = []; + let currentLayer: Path[] = []; + + this.paths.forEach((path) => { + if (path.travelType === PathType.Extrusion) { + currentLayer.push(path); + } else { + if ( + path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]) && + currentLayer.find((p) => p.travelType === PathType.Extrusion) + ) { + layers.push(currentLayer); + currentLayer = []; + } + currentLayer.push(path); + } + }); + + layers.push(currentLayer); + + return layers; + } + + extrusions(): Path[] { + return this.paths.filter((path) => path.travelType === PathType.Extrusion); + } + + travels(): Path[] { + return this.paths.filter((path) => path.travelType === PathType.Travel); + } +} diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 00000000..8a89bdf3 --- /dev/null +++ b/src/path.ts @@ -0,0 +1,61 @@ +/* eslint-disable no-unused-vars */ +import { BufferGeometry, Vector3 } from 'three'; +import { ExtrusionGeometry } from './extrusion-geometry'; + +export enum PathType { + Travel = 'Travel', + Extrusion = 'Extrusion' +} + +export class Path { + public travelType: PathType; + public vertices: number[]; + extrusionWidth: number; + lineHeight: number; + tool: 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; + } + + 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(): BufferGeometry { + if (this.vertices.length < 3) { + return new BufferGeometry(); + } + + return new ExtrusionGeometry(this.path(), this.extrusionWidth, this.lineHeight, 4); + } + + line(): BufferGeometry { + return new BufferGeometry().setFromPoints(this.path()); + } +} diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 276d53ff..839e9e9e 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -1,13 +1,12 @@ -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 { GridHelper } from './gridHelper'; import { LineBox } from './lineBox'; 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 { AmbientLight, @@ -17,7 +16,6 @@ import { Color, ColorRepresentation, Euler, - Float32BufferAttribute, Fog, Group, LineBasicMaterial, @@ -27,38 +25,14 @@ import { PointLight, REVISION, Scene, - Vector3, WebGLRenderer } from 'three'; -import { ExtrusionGeometry } from './extrusion-geometry'; - -type RenderLayer = { extrusion: number[]; travel: number[]; z: number; height: number }; -type GVector3 = { +type BuildVolume = { x: number; y: number; z: number; }; -type Arc = GVector3 & { r: number; i: number; j: number }; - -type Point = GVector3; -type BuildVolume = 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; @@ -72,7 +46,6 @@ export type GCodePreviewOptions = { lineWidth?: number; lineHeight?: number; nonTravelMoves?: string[]; - minLayerThreshold?: number; renderExtrusion?: boolean; renderTravel?: boolean; startLayer?: number; @@ -81,33 +54,16 @@ export type GCodePreviewOptions = { toolColors?: Record; disableGradient?: boolean; extrusionWidth?: number; - /** @experimental */ renderTubes?: boolean; /** * @deprecated Please see the demo how to implement drag and drop. */ allowDragNDrop?: boolean; - /** - * @deprecated Please use the `canvas` param instead. - */ - targetId?: string; /** @experimental */ devMode?: boolean | DevModeOptions; }; -const target = { - h: 0, - s: 0, - l: 0 -}; - export class WebGLPreview { - minLayerThreshold = 0.05; - parser: Parser; - /** - * @deprecated Please use the `canvas` param instead. - */ - targetId?: string; scene: Scene; camera: PerspectiveCamera; renderer: WebGLRenderer; @@ -132,9 +88,9 @@ export class WebGLPreview { nonTravelmoves: string[] = []; disableGradient = false; - // gcode processing state - private state: State = State.initial; - private beyondFirstMove = false; // TODO: move to state + interpreter = new Interpreter(); + job = new Job(); + parser = new Parser(); // rendering private group?: Group; @@ -142,8 +98,8 @@ 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 renderLayerIndex?: number; + private _geometries: Record = {}; // colors private _backgroundColor = new Color(0xe0e0e0); @@ -161,14 +117,11 @@ export class WebGLPreview { private devGui?: DevGUI; constructor(opts: GCodePreviewOptions) { - this.minLayerThreshold = opts.minLayerThreshold ?? this.minLayerThreshold; - this.parser = new Parser(this.minLayerThreshold); this.scene = new Scene(); this.scene.background = this._backgroundColor; if (opts.backgroundColor !== undefined) { this.backgroundColor = new Color(opts.backgroundColor); } - this.targetId = opts.targetId; this.endLayer = opts.endLayer; this.startLayer = opts.startLayer; this.lineWidth = opts.lineWidth; @@ -210,28 +163,11 @@ export class WebGLPreview { console.info('Using THREE r' + REVISION); console.debug('opts', opts); - if (this.targetId) { - console.warn('`targetId` is deprecated and will removed in the future. Use `canvas` instead.'); - } - - if (!opts.canvas) { - if (!this.targetId) { - throw Error('Set either opts.canvas or opts.targetId'); - } - const container = document.getElementById(this.targetId); - if (!container) throw new Error('Unable to find element ' + this.targetId); - - this.renderer = new WebGLRenderer({ preserveDrawingBuffer: true }); - this.canvas = this.renderer.domElement; - - container.appendChild(this.canvas); - } else { - this.canvas = opts.canvas; - this.renderer = new WebGLRenderer({ - canvas: this.canvas, - preserveDrawingBuffer: true - }); - } + this.canvas = opts.canvas; + this.renderer = new WebGLRenderer({ + canvas: this.canvas, + preserveDrawingBuffer: true + }); this.camera = new PerspectiveCamera(25, this.canvas.offsetWidth / this.canvas.offsetHeight, 10, 5000); this.camera.position.fromArray(this.initialCameraPosition); @@ -244,9 +180,6 @@ export class WebGLPreview { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.initScene(); this.animate(); - - if (opts.allowDragNDrop) this._enableDropHandler(); - this.initStats(); } @@ -265,18 +198,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; } @@ -307,23 +228,6 @@ 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()); - } - - // convert from 1-based to 0-based - get maxLayerIndex(): number { - return (this.endLayer ?? this.layers.length) - 1; - } - - // convert from 1-based to 0-based - get minLayerIndex(): number { - return this.singleLayerMode ? this.maxLayerIndex : (this.startLayer ?? 0) - 1; - } - /** @internal */ animate(): void { this.animationFrameId = requestAnimationFrame(() => this.animate()); @@ -333,7 +237,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(); } @@ -383,14 +288,10 @@ 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.renderGeometries(); + this.renderLines(); this.scene.add(this.group); this.renderer.render(this.scene, this.camera); @@ -403,13 +304,20 @@ export class WebGLPreview { this.initScene(); this.renderLayerIndex = 0; + + if (this.job.layers() === null) { + console.warn('Job is not planar'); + this.render(); + return; + } + return this.renderFrameLoop(layerCount > 0 ? layerCount : 1); } private renderFrameLoop(layerCount: number): Promise { return new Promise((resolve) => { const loop = () => { - if (this.renderLayerIndex > this.layers.length - 1) { + if (this.renderLayerIndex >= this.job.layers().length - 1) { resolve(); } else { this.renderFrame(layerCount); @@ -423,130 +331,21 @@ 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(); - - 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); - } + const endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers().length - 1); + const pathsToRender = this.job + .layers() + .slice(this.renderLayerIndex, endIndex) + .flatMap((l) => l); - /** @internal */ - doRenderExtrusion(layer: RenderLayer, index: number): void { - if (this.renderExtrusion) { - let extrusionColor = this.currentToolColor; + this.renderGeometries(pathsToRender.filter((path) => path.travelType === 'Extrusion')); + this.renderLines( + pathsToRender.filter((path) => path.travelType === 'Travel'), + pathsToRender.filter((path) => path.travelType === 'Extrusion') + ); - 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()); - } - } - } + this.renderLayerIndex = endIndex; - 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; + this.scene.add(this.group); } /** @internal */ @@ -564,7 +363,8 @@ export class WebGLPreview { // reset parser & processing state clear(): void { this.resetState(); - this.parser = new Parser(this.minLayerThreshold); + this.parser = new Parser(); + this.job = new Job(); } // reset processing state @@ -572,9 +372,6 @@ export class WebGLPreview { this.startLayer = 1; this.endLayer = Infinity; this.singleLayerMode = false; - - this.beyondFirstMove = false; - this.state = State.initial; this.devGui?.reset(); this._geometries = {}; } @@ -587,177 +384,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 { - if (typeof this.lineWidth === 'number' && this.lineWidth > 0) { - this.addThickLine(vertices, color); - return; - } - - const geometry = new BufferGeometry(); - geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); - this.disposables.push(geometry); - const material = new LineBasicMaterial({ color: color }); - this.disposables.push(material); - const lineSegments = new LineSegments(geometry, material); - - this.group?.add(lineSegments); - } - - /** @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); - }); - } - - /** @internal */ - addThickLine(vertices: number[], color: number): void { - if (!vertices.length || !this.lineWidth) return; - - const geometry = new LineSegmentsGeometry(); - this.disposables.push(geometry); - - const matLine = new LineMaterial({ - color: color, - linewidth: this.lineWidth / (1000 * window.devicePixelRatio) - }); - this.disposables.push(matLine); - - geometry.setPositions(vertices); - const line = new LineSegments2(geometry, matLine); - - this.group?.add(line); - } - dispose(): void { this.disposables.forEach((d) => d.dispose()); this.disposables = []; @@ -772,47 +398,64 @@ export class WebGLPreview { this.animationFrameId = undefined; } - private _enableDropHandler() { - console.warn('Drag and drop is deprecated as a library feature. See the demo how to implement your own.'); - this.canvas.addEventListener('dragover', (evt) => { - evt.stopPropagation(); - evt.preventDefault(); - if (evt.dataTransfer) evt.dataTransfer.dropEffect = 'copy'; - this.canvas.classList.add('dragging'); - }); + private renderLines(travels = this.job.travels(), extrusions = this.job.extrusions()): void { + if (this.renderTravel) { + const material = new LineBasicMaterial({ color: this._travelColor, linewidth: this.lineWidth }); + this.disposables.push(material); - this.canvas.addEventListener('dragleave', (evt) => { - evt.stopPropagation(); - evt.preventDefault(); - this.canvas.classList.remove('dragging'); - }); + travels.forEach((path) => { + const geometry = path.line(); + const line = new LineSegments(geometry, material); + this.group?.add(line); + }); + } - this.canvas.addEventListener('drop', async (evt) => { - evt.stopPropagation(); - evt.preventDefault(); - this.canvas.classList.remove('dragging'); - const files: FileList | [] = evt.dataTransfer?.files ?? []; - const file = files[0]; + if (this.renderExtrusion && !this.renderTubes) { + const lineMaterials = {} as Record; - this.clear(); + if (Array.isArray(this._extrusionColor)) { + this._extrusionColor.forEach((color, index) => { + lineMaterials[index] = new LineBasicMaterial({ color, linewidth: this.lineWidth }); + }); + } else { + lineMaterials[0] = new LineBasicMaterial({ + color: this._extrusionColor, + linewidth: this.lineWidth + }); + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this._readFromStream(file.stream() as unknown as ReadableStream); - this.render(); - }); + extrusions.forEach((path) => { + const geometry = path.line(); + const line = new LineSegments(geometry, lineMaterials[path.tool]); + this.group?.add(line); + }); + } } - 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 renderGeometries(paths = this.job.extrusions()): void { + if (Object.keys(this._geometries).length === 0 && this.renderTubes) { + let color: number; + paths.forEach((path) => { + if (Array.isArray(this._extrusionColor)) { + color = this._extrusionColor[path.tool].getHex(); + } else { + color = this._extrusionColor.getHex(); } - } + + this._geometries[color] ||= []; + this._geometries[color].push(path.geometry()); + }); } + + for (const color in this._geometries) { + const batchedMesh = this.createBatchMesh(parseInt(color)); + this._geometries[color].forEach((geometry) => { + const geometryId = batchedMesh.addGeometry(geometry); + batchedMesh.addInstance(geometryId); + }); + this._geometries[color] = []; + } + this._geometries = {}; } private createBatchMesh(color: number): BatchedMesh {