From 66be4dc19d63208d5627f853d2cf7b9f2c69ee24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Sun, 13 Oct 2024 11:20:55 -0400 Subject: [PATCH 01/14] Introduce an interpreter to extract command interpretation logic (#211) * Interpreter prototype * arc support * units * Simplify toolchange * travelType * remove setInches * remove targetId * layers * render lines * extract machine * wip * Make it work with rerenders * Remove some layer references * Rename Machine to Job * Simplify parsing attributes * Am I going too far? * get rid of geometries once used * the geometries disposition is handled by the batchMesh * Fix tests * Bring back some code * Bring back progressive rendering * update dev-gui with job * Test Path * First interpreter tests * Test G0 and G1 * Test everything by G2 * Adding missing codes * Minimize diff on app.js * Leave G21 for a future PR * Job tests (and fixes!) * Keep G28 for a separate PR * Improve tests * Code improvements * Retractions are travel --- demo/js/app.js | 22 +- src/__tests__/gcode-parser.ts | 217 +++--------- src/__tests__/interpreter.ts | 286 ++++++++++++++++ src/__tests__/job.ts | 159 +++++++++ src/__tests__/path.ts | 129 +++++++ src/__tests__/preserving-parser.ts | 6 +- src/__tests__/webgl-preview.ts | 24 +- src/dev-gui.ts | 12 +- src/gcode-parser.ts | 200 ++++------- src/interpreter.ts | 168 ++++++++++ src/job.ts | 71 ++++ src/path.ts | 62 ++++ src/webgl-preview.ts | 519 +++++------------------------ 13 files changed, 1099 insertions(+), 776 deletions(-) create mode 100644 src/__tests__/interpreter.ts create mode 100644 src/__tests__/job.ts create mode 100644 src/__tests__/path.ts create mode 100644 src/interpreter.ts create mode 100644 src/job.ts create mode 100644 src/path.ts diff --git a/demo/js/app.js b/demo/js/app.js index 41592253..2c4872db 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -53,7 +53,6 @@ export const app = (window.app = createApp({ const updateUI = async () => { const { parser, - layers, extrusionColor, topLayerColor, lastSegmentColor, @@ -65,16 +64,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(), @@ -93,7 +93,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) => { @@ -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(Math.ceil(preview.job.layers().length / 60)); } else { preview.render(); } 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..3a9cd043 --- /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(); + const points = result.attributes['instanceStart'].array; + expect(points).toEqual(Float32Array.from([0, 0, 0, 1, 2, 3])); +}); + +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(); + 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/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..27e061fb --- /dev/null +++ b/src/path.ts @@ -0,0 +1,62 @@ +/* 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 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(): LineSegmentsGeometry { + return new LineSegmentsGeometry().setPositions(this.vertices); + } +} diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 88519233..52a078ee 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -1,17 +1,19 @@ -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 { 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 { AmbientLight, BatchedMesh, + BufferGeometry, Color, ColorRepresentation, Euler, @@ -22,38 +24,9 @@ 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 = { - 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; @@ -66,7 +39,6 @@ export type GCodePreviewOptions = { lineWidth?: number; lineHeight?: number; nonTravelMoves?: string[]; - minLayerThreshold?: number; renderExtrusion?: boolean; renderTravel?: boolean; startLayer?: number; @@ -75,33 +47,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; @@ -126,9 +81,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; @@ -136,8 +91,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); @@ -155,14 +110,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 ?? 1; @@ -204,28 +156,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); @@ -238,9 +173,6 @@ export class WebGLPreview { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.initScene(); this.animate(); - - if (opts.allowDragNDrop) this._enableDropHandler(); - this.initStats(); } @@ -259,18 +191,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,23 +221,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()); @@ -327,7 +230,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 +276,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); @@ -392,13 +292,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); @@ -412,136 +319,28 @@ 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 endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers().length - 1); + const pathsToRender = this.job + .layers() + .slice(this.renderLayerIndex, endIndex) + .flatMap((l) => l); - 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()); - } - } - } + this.renderGeometries(pathsToRender.filter((path) => path.travelType === 'Extrusion')); + this.renderLines( + pathsToRender.filter((path) => path.travelType === 'Travel'), + pathsToRender.filter((path) => path.travelType === 'Extrusion') + ); - if (this.renderTravel) { - this.addLine(layer.travel, this._travelColor.getHex()); - } - } + this.renderLayerIndex = endIndex; - 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); } // 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 @@ -549,9 +348,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 = {}; } @@ -564,158 +360,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 = []; @@ -730,47 +374,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 LineMaterial({ 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 LineSegments2(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 LineMaterial({ color, linewidth: this.lineWidth }); + }); + } else { + lineMaterials[0] = new LineMaterial({ + 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 LineSegments2(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 { From 25e7b8d37f5f6b606d4435bae2895738ed94320e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Sun, 13 Oct 2024 11:28:22 -0400 Subject: [PATCH 02/14] Implement G21 (#218) --- src/__tests__/interpreter.ts | 10 ++++++++++ src/gcode-parser.ts | 3 +++ src/interpreter.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts index 49b58cf6..586c1c8e 100644 --- a/src/__tests__/interpreter.ts +++ b/src/__tests__/interpreter.ts @@ -197,6 +197,16 @@ test('.G20 sets the units to inches', () => { 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('.t0 sets the tool to 0', () => { const command = new GCodeCommand('T0', 't0', {}); const interpreter = new Interpreter(); diff --git a/src/gcode-parser.ts b/src/gcode-parser.ts index c3fbccd2..5c7b2990 100644 --- a/src/gcode-parser.ts +++ b/src/gcode-parser.ts @@ -62,6 +62,7 @@ export enum Code { G2 = 'G2', G3 = 'G3', G20 = 'G20', + G21 = 'G21', T0 = 'T0', T1 = 'T1', T2 = 'T2', @@ -98,6 +99,8 @@ export class GCodeCommand { return Code.G3; case 'g20': return Code.G20; + case 'g21': + return Code.G21; case 't0': return Code.T0; case 't1': diff --git a/src/interpreter.ts b/src/interpreter.ts index 65b09e15..a0abc8cb 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -134,6 +134,10 @@ export class Interpreter { job.state.units = 'in'; } + G21(command: GCodeCommand, job: Job): void { + job.state.units = 'mm'; + } + T0(command: GCodeCommand, job: Job): void { job.state.tool = 0; } From 11af24f0e45a82d255e4b1a80ba683dc859f92e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Sun, 13 Oct 2024 14:40:03 -0400 Subject: [PATCH 03/14] Unremove target --- src/webgl-preview.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 52a078ee..2e818c9a 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -52,11 +52,19 @@ export type GCodePreviewOptions = { * @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; }; export class WebGLPreview { + /** + * @deprecated Please use the `canvas` param instead. + */ + targetId?: string; scene: Scene; camera: PerspectiveCamera; renderer: WebGLRenderer; @@ -115,6 +123,7 @@ export class WebGLPreview { 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 ?? 1; @@ -156,11 +165,28 @@ export class WebGLPreview { console.info('Using THREE r' + REVISION); console.debug('opts', opts); - this.canvas = opts.canvas; - this.renderer = new WebGLRenderer({ - canvas: this.canvas, - preserveDrawingBuffer: true - }); + 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.camera = new PerspectiveCamera(25, this.canvas.offsetWidth / this.canvas.offsetHeight, 10, 5000); this.camera.position.fromArray(this.initialCameraPosition); From 7bb105b5640407e4bebf465870880c6aede9d9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Sun, 13 Oct 2024 14:43:57 -0400 Subject: [PATCH 04/14] Undelete drag-n-drop --- src/webgl-preview.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 2e818c9a..5d3b6456 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -199,6 +199,9 @@ export class WebGLPreview { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.initScene(); this.animate(); + + if (opts.allowDragNDrop) this._enableDropHandler(); + this.initStats(); } @@ -400,6 +403,36 @@ 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'); + }); + + this.canvas.addEventListener('dragleave', (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + this.canvas.classList.remove('dragging'); + }); + + 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]; + + this.clear(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await this._readFromStream(file.stream() as unknown as ReadableStream); + this.render(); + }); + } + private renderLines(travels = this.job.travels(), extrusions = this.job.extrusions()): void { if (this.renderTravel) { const material = new LineMaterial({ color: this._travelColor, linewidth: this.lineWidth }); From c4937bf1300a745c3d292c4d1cf3da9724273d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Sun, 13 Oct 2024 19:06:26 -0400 Subject: [PATCH 05/14] Implement G28 (home) (#219) --- src/__tests__/interpreter.ts | 14 ++++++++++++++ src/gcode-parser.ts | 3 +++ src/interpreter.ts | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts index 586c1c8e..d53b6a2d 100644 --- a/src/__tests__/interpreter.ts +++ b/src/__tests__/interpreter.ts @@ -207,6 +207,20 @@ test('.G21 sets the units to millimeters', () => { 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(); diff --git a/src/gcode-parser.ts b/src/gcode-parser.ts index 5c7b2990..27320362 100644 --- a/src/gcode-parser.ts +++ b/src/gcode-parser.ts @@ -63,6 +63,7 @@ export enum Code { G3 = 'G3', G20 = 'G20', G21 = 'G21', + G28 = 'G28', T0 = 'T0', T1 = 'T1', T2 = 'T2', @@ -101,6 +102,8 @@ export class GCodeCommand { return Code.G20; case 'g21': return Code.G21; + case 'g28': + return Code.G28; case 't0': return Code.T0; case 't1': diff --git a/src/interpreter.ts b/src/interpreter.ts index a0abc8cb..b91ebe7b 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -138,6 +138,12 @@ export class Interpreter { 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; } From b3fee93ace1cf7e529a47cbc208a929699d2591f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 15 Oct 2024 23:22:04 -0400 Subject: [PATCH 06/14] Improve the paths subsets of jobs (#220) * Introduce the traveltype indexer * Add the layer indexer * don't clear for all types * Fix the calls to the getters * Cleaner error management * Fix all tests --- demo/js/app.js | 12 +-- src/__tests__/interpreter.ts | 72 ++++++++------- src/__tests__/job.ts | 174 ++++++++++++++++++++++++++--------- src/__tests__/path.ts | 12 ++- src/interpreter.ts | 27 +++--- src/job.ts | 147 +++++++++++++++++++++++------ src/path.ts | 34 ++++--- src/webgl-preview.ts | 15 ++- 8 files changed, 344 insertions(+), 149 deletions(-) diff --git a/demo/js/app.js b/demo/js/app.js index 2c4872db..9daf46b9 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -70,11 +70,11 @@ export const app = (window.app = createApp({ const { thumbnails } = parser.metadata; thumbnail.value = thumbnails['220x124']?.src; - layerCount.value = job.layers()?.length; + layerCount.value = job.layers?.length; const colors = extrusionColor instanceof Array ? extrusionColor : [extrusionColor]; const currentSettings = { - maxLayer: job.layers()?.length, - endLayer: job.layers()?.length, + maxLayer: job.layers?.length, + endLayer: job.layers?.length, singleLayerMode, renderTravel, travelColor: '#' + travelColor.getHexString(), @@ -93,7 +93,7 @@ export const app = (window.app = createApp({ }; Object.assign(settings.value, currentSettings); - preview.endLayer = job.layers()?.length; + preview.endLayer = job.layers?.length; }; const loadGCodeFromServer = async (filename) => { @@ -121,12 +121,12 @@ export const app = (window.app = createApp({ const render = async () => { debounce(async () => { if (loadProgressive) { - if (preview.job.layers() === null) { + if (preview.job.layers === null) { console.warn('Job is not planar'); preview.render(); return; } - await preview.renderAnimated(Math.ceil(preview.job.layers().length / 60)); + await preview.renderAnimated(Math.ceil(preview.job.layers?.length / 60)); } else { preview.render(); } diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts index d53b6a2d..98fb1910 100644 --- a/src/__tests__/interpreter.ts +++ b/src/__tests__/interpreter.ts @@ -74,14 +74,15 @@ test('.G0 starts a path if the job has none', () => { 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); + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath).not.toBeNull(); + expect(job.inprogressPath?.vertices.length).toEqual(6); + expect(job.inprogressPath?.vertices[0]).toEqual(0); + expect(job.inprogressPath?.vertices[1]).toEqual(0); + expect(job.inprogressPath?.vertices[2]).toEqual(0); + expect(job.inprogressPath?.vertices[3]).toEqual(1); + expect(job.inprogressPath?.vertices[4]).toEqual(2); + expect(job.inprogressPath?.vertices[5]).toEqual(0); }); test('.G0 starts a path if the job has none, starting at the job current state', () => { @@ -94,12 +95,12 @@ test('.G0 starts a path if the job has none, starting at the job current state', 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); + 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('.G0 continues the path if the job has one', () => { @@ -113,11 +114,11 @@ test('.G0 continues the path if the job has one', () => { 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); + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.vertices.length).toEqual(9); + expect(job.inprogressPath?.vertices[6]).toEqual(3); + expect(job.inprogressPath?.vertices[7]).toEqual(4); + expect(job.inprogressPath?.vertices[8]).toEqual(5); }); test(".G0 assigns the travel type if there's no extrusion", () => { @@ -127,8 +128,8 @@ test(".G0 assigns the travel type if there's no extrusion", () => { interpreter.G0(command, job); - expect(job.paths.length).toEqual(1); - expect(job.paths[0].travelType).toEqual(PathType.Travel); + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual(PathType.Travel); }); test(".G0 assigns the extrusion type if there's extrusion", () => { @@ -138,8 +139,8 @@ test(".G0 assigns the extrusion type if there's extrusion", () => { interpreter.G0(command, job); - expect(job.paths.length).toEqual(1); - expect(job.paths[0].travelType).toEqual('Extrusion'); + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual('Extrusion'); }); test('.G0 assigns the travel type if the extrusion is a retraction', () => { @@ -149,8 +150,19 @@ test('.G0 assigns the travel type if the extrusion is a retraction', () => { interpreter.G0(command, job); - expect(job.paths.length).toEqual(1); - expect(job.paths[0].travelType).toEqual('Travel'); + expect(job.paths.length).toEqual(0); + expect(job.inprogressPath?.travelType).toEqual('Travel'); +}); + +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(0); + expect(job.inprogressPath?.travelType).toEqual('Travel'); }); test('.G0 starts a new path if the travel type changes from Travel to Extrusion', () => { @@ -162,9 +174,8 @@ test('.G0 starts a new path if the travel type changes from Travel to Extrusion' interpreter.G0(command2, job); - expect(job.paths.length).toEqual(2); - expect(job.paths[0].travelType).toEqual('Travel'); - expect(job.paths[1].travelType).toEqual('Extrusion'); + expect(job.paths.length).toEqual(1); + expect(job.inprogressPath?.travelType).toEqual('Extrusion'); }); test('.G0 starts a new path if the travel type changes from Extrusion to Travel', () => { @@ -176,9 +187,8 @@ test('.G0 starts a new path if the travel type changes from Extrusion to Travel' interpreter.G0(command2, job); - expect(job.paths.length).toEqual(2); - expect(job.paths[0].travelType).toEqual('Extrusion'); - expect(job.paths[1].travelType).toEqual('Travel'); + expect(job.paths.length).toEqual(1); + expect(job.inprogressPath?.travelType).toEqual('Travel'); }); test('.G1 is an alias to .G0', () => { diff --git a/src/__tests__/job.ts b/src/__tests__/job.ts index 8d1d0538..7968fa60 100644 --- a/src/__tests__/job.ts +++ b/src/__tests__/job.ts @@ -12,8 +12,14 @@ 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]); + 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); }); @@ -21,8 +27,14 @@ describe('.isPlanar', () => { 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]); + 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); }); @@ -30,9 +42,19 @@ describe('.isPlanar', () => { 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]); + 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); }); @@ -42,19 +64,31 @@ 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]); + 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); + 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]); + 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(); + const layers = job.layers; expect(layers).not.toBeNull(); expect(layers).toBeInstanceOf(Array); @@ -65,10 +99,16 @@ describe('.layers', () => { 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]); + 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(); + const layers = job.layers; expect(layers).not.toBeNull(); expect(layers).toBeInstanceOf(Array); @@ -80,12 +120,24 @@ describe('.layers', () => { 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(); + 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); @@ -97,13 +149,28 @@ describe('.layers', () => { 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(); + 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); @@ -117,11 +184,20 @@ 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(); + 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); @@ -136,12 +212,24 @@ 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(); + 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); @@ -152,8 +240,8 @@ describe('.travels', () => { }); }); -function append_path(job, travelType, vertices) { +function append_path(job, travelType, points) { const path = new Path(travelType, 0.6, 0.2, job.state.tool); - path.vertices = vertices; - job.paths.push(path); + points.forEach((point: [number, number, number]) => path.addPoint(...point)); + job.addPath(path); } diff --git a/src/__tests__/path.ts b/src/__tests__/path.ts index 3a9cd043..ab3de8cb 100644 --- a/src/__tests__/path.ts +++ b/src/__tests__/path.ts @@ -67,7 +67,8 @@ test('.path returns an array of Vector3', () => { 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]; + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); const result = path.geometry() as ExtrusionGeometry; @@ -80,7 +81,8 @@ test('.geometry returns an ExtrusionGeometry from the path', () => { 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]; + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); const result = path.geometry() as ExtrusionGeometry; @@ -90,7 +92,8 @@ test('.geometry returns an ExtrusionGeometry with the path extrusion width', () 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]; + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); const result = path.geometry() as ExtrusionGeometry; @@ -109,7 +112,8 @@ test('.geometry returns an empty BufferGeometry if there are less than 3 vertice 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]; + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); const result = path.line(); diff --git a/src/interpreter.ts b/src/interpreter.ts index b91ebe7b..40523d04 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -17,18 +17,18 @@ export class Interpreter { const { x, y, z, e } = command.params; const { state } = job; - let lastPath = job.paths[job.paths.length - 1]; + let currentPath = job.inprogressPath; const pathType = e > 0 ? PathType.Extrusion : PathType.Travel; - if (lastPath === undefined || lastPath.travelType !== pathType) { - lastPath = this.breakPath(job, pathType); + 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; - lastPath.addPoint(state.x, state.y, state.z); + currentPath.addPoint(state.x, state.y, state.z); } G1 = this.G0; @@ -39,11 +39,11 @@ export class Interpreter { const { state } = job; const cw = command.code === Code.G2; - let lastPath = job.paths[job.paths.length - 1]; + let currentPath = job.inprogressPath; const pathType = e ? PathType.Extrusion : PathType.Travel; - if (lastPath === undefined || lastPath.travelType !== pathType) { - lastPath = this.breakPath(job, pathType); + if (currentPath === undefined || currentPath.travelType !== pathType) { + currentPath = this.breakPath(job, pathType); } if (r) { @@ -118,14 +118,14 @@ export class Interpreter { px = centerX + arcRadius * Math.cos(currentAngle); py = centerY + arcRadius * Math.sin(currentAngle); pz += zStep; - lastPath.addPoint(px, py, pz); + currentPath.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); + currentPath.addPoint(state.x, state.y, state.z); } G3 = this.G2; @@ -170,9 +170,10 @@ export class Interpreter { } 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; + 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 index 47f332ed..de372a45 100644 --- a/src/job.ts +++ b/src/job.ts @@ -18,54 +18,141 @@ export class State { export class Job { paths: Path[]; state: State; + private travelPaths: Path[] = []; + private extrusionPaths: Path[] = []; + private layersPaths: Path[][] | null; + private indexers: Indexer[]; + inprogressPath: Path | undefined; constructor(state?: State) { this.paths = []; this.state = state || State.initial; + this.layersPaths = [[]]; + this.indexers = [ + new TravelTypeIndexer({ travel: this.travelPaths, extrusion: this.extrusionPaths }), + new LayersIndexer(this.layersPaths) + ]; } - isPlanar(): boolean { - return ( - this.paths.find( - (path) => - path.travelType === PathType.Extrusion && path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]) - ) === undefined - ); + get extrusions(): Path[] { + return this.extrusionPaths; + } + + get travels(): Path[] { + return this.travelPaths; + } + + get layers(): Path[][] | null { + return this.layersPaths; } - layers(): Path[][] | null { - if (!this.isPlanar()) { - return null; + finishPath(): void { + if (this.inprogressPath === undefined) { + return; } + if (this.inprogressPath.vertices.length > 0) { + this.addPath(this.inprogressPath); + } + } + + addPath(path: Path): void { + this.paths.push(path); + this.indexPath(path); + } - 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 = []; + isPlanar(): boolean { + return this.paths.find((path) => path.travelType === PathType.Extrusion && path.hasVerticalMoves()) === undefined; + } + + private indexPath(path: Path): void { + this.indexers.forEach((indexer) => { + try { + indexer.sortIn(path); + } catch (e) { + if (e instanceof NonApplicableIndexer) { + if (e instanceof NonPlanarPathError) { + this.layersPaths = null; + } + const i = this.indexers.indexOf(indexer); + this.indexers.splice(i, 1); + } else { + throw e; } - currentLayer.push(path); } }); + } +} + +class NonApplicableIndexer extends Error {} +class Indexer { + protected indexes: Record | Path[][]; + constructor(indexes: Record | Path[][]) { + this.indexes = indexes; + } + sortIn(path: Path): void { + path; + throw new Error('Method not implemented.'); + } +} - layers.push(currentLayer); +class TravelTypeIndexer extends Indexer { + protected declare indexes: Record; + constructor(indexes: Record) { + super(indexes); + } - return layers; + sortIn(path: Path): void { + if (path.travelType === PathType.Extrusion) { + this.indexes.extrusion.push(path); + } else { + this.indexes.travel.push(path); + } } +} - extrusions(): Path[] { - return this.paths.filter((path) => path.travelType === PathType.Extrusion); +class NonPlanarPathError extends NonApplicableIndexer { + constructor() { + super("Non-planar paths can't be indexed by layer"); + } +} +class LayersIndexer extends Indexer { + protected declare indexes: Path[][]; + constructor(indexes: Path[][]) { + super(indexes); + } + + sortIn(path: Path): void { + if (path.travelType === PathType.Extrusion && path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2])) { + throw new NonPlanarPathError(); + } + + if (path.travelType === PathType.Extrusion) { + this.lastLayer().push(path); + } else { + if ( + path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]) && + this.lastLayer().find((p) => p.travelType === PathType.Extrusion) + ) { + this.createLayer(); + } + this.lastLayer().push(path); + } + } + + private lastLayer(): Path[] { + if (this.indexes === undefined) { + this.indexes = [[]]; + } + + if (this.indexes[this.indexes.length - 1] === undefined) { + this.createLayer(); + return this.lastLayer(); + } + return this.indexes[this.indexes.length - 1]; } - travels(): Path[] { - return this.paths.filter((path) => path.travelType === PathType.Travel); + private createLayer(): void { + const newLayer: Path[] = []; + this.indexes.push(newLayer); } } diff --git a/src/path.ts b/src/path.ts index 27e061fb..077824fe 100644 --- a/src/path.ts +++ b/src/path.ts @@ -10,31 +10,35 @@ export enum PathType { export class Path { public travelType: PathType; - public vertices: number[]; - extrusionWidth: number; - lineHeight: number; - tool: number; + 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._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); + this._vertices.push(x, y, z); } checkLineContinuity(x: number, y: number, z: number): boolean { - if (this.vertices.length < 3) { + 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]; + 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; } @@ -42,14 +46,14 @@ export class Path { 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])); + 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) { + if (this._vertices.length < 3) { return new BufferGeometry(); } @@ -59,4 +63,8 @@ export class Path { line(): LineSegmentsGeometry { return new LineSegmentsGeometry().setPositions(this.vertices); } + + 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 5d3b6456..f5364a17 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -322,7 +322,7 @@ export class WebGLPreview { this.renderLayerIndex = 0; - if (this.job.layers() === null) { + if (this.job.layers === null) { console.warn('Job is not planar'); this.render(); return; @@ -334,7 +334,7 @@ export class WebGLPreview { private renderFrameLoop(layerCount: number): Promise { return new Promise((resolve) => { const loop = () => { - if (this.renderLayerIndex >= this.job.layers().length - 1) { + if (this.renderLayerIndex >= this.job.layers?.length - 1) { resolve(); } else { this.renderFrame(layerCount); @@ -348,11 +348,8 @@ export class WebGLPreview { private renderFrame(layerCount: number): void { this.group = this.createGroup('layer' + this.renderLayerIndex); - const endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers().length - 1); - const pathsToRender = this.job - .layers() - .slice(this.renderLayerIndex, endIndex) - .flatMap((l) => l); + const endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers?.length - 1); + const pathsToRender = this.job.layers?.slice(this.renderLayerIndex, endIndex)?.flatMap((l) => l); this.renderGeometries(pathsToRender.filter((path) => path.travelType === 'Extrusion')); this.renderLines( @@ -433,7 +430,7 @@ export class WebGLPreview { }); } - private renderLines(travels = this.job.travels(), extrusions = this.job.extrusions()): void { + private renderLines(travels = this.job.travels, extrusions = this.job.extrusions): void { if (this.renderTravel) { const material = new LineMaterial({ color: this._travelColor, linewidth: this.lineWidth }); this.disposables.push(material); @@ -467,7 +464,7 @@ export class WebGLPreview { } } - private renderGeometries(paths = this.job.extrusions()): void { + private renderGeometries(paths = this.job.extrusions): void { if (Object.keys(this._geometries).length === 0 && this.renderTubes) { let color: number; paths.forEach((path) => { From c00ec976fd02294bf4695cbc1900ffdfc4fbd9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 15 Oct 2024 23:32:16 -0400 Subject: [PATCH 07/14] width-and-height-for-extrusions (#227) --- demo/js/presets.js | 1 + src/__tests__/path.ts | 116 ++++++++++++++++++++++++++---------------- src/path.ts | 9 +++- src/webgl-preview.ts | 8 +-- 4 files changed, 84 insertions(+), 50 deletions(-) diff --git a/demo/js/presets.js b/demo/js/presets.js index a42d6f62..b9f6d7b4 100644 --- a/demo/js/presets.js +++ b/demo/js/presets.js @@ -43,6 +43,7 @@ export const presets = { title: 'Vase mode', file: 'gcodes/vase.gcode', lineWidth: 0, + lineHeight: 0.4, renderExtrusion: true, renderTubes: true, extrusionColor: ['rgb(84,74,187)'], diff --git a/src/__tests__/path.ts b/src/__tests__/path.ts index ab3de8cb..0684e84e 100644 --- a/src/__tests__/path.ts +++ b/src/__tests__/path.ts @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest'; +import { test, expect, describe } from 'vitest'; import { Path, PathType } from '../path'; import { ExtrusionGeometry } from '../extrusion-geometry'; import { BufferGeometry } from 'three'; @@ -64,70 +64,96 @@ test('.path returns an array of Vector3', () => { 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); +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); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - const result = path.geometry() as ExtrusionGeometry; + 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); -}); + 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); + 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); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - const result = path.geometry() as ExtrusionGeometry; + const result = path.geometry() as ExtrusionGeometry; - expect(result.parameters.lineWidth).toEqual(9); -}); + 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); + 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); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - const result = path.geometry() as ExtrusionGeometry; + const result = path.geometry() as ExtrusionGeometry; - expect(result.parameters.lineHeight).toEqual(5); -}); + 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); + test('returns an ExtrusionGeometry with the extrusionWidthOverride when passed', () => { + const path = new Path(PathType.Travel, 9, undefined, undefined); - const result = path.geometry(); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - expect(result).not.toBeNull(); - expect(result).toBeInstanceOf(BufferGeometry); -}); + const result = path.geometry({ extrusionWidthOverride: 2 }) as ExtrusionGeometry; -test('.line returns a BufferGeometry from the path', () => { - const path = new Path(PathType.Travel, undefined, undefined, undefined); + expect(result.parameters.lineWidth).toEqual(2); + }); - path.addPoint(0, 0, 0); - path.addPoint(1, 2, 3); + test('returns an ExtrusionGeometry with the lineHeightOverride when passed', () => { + const path = new Path(PathType.Travel, undefined, 5, undefined); - const result = path.line(); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - expect(result).not.toBeNull(); - const points = result.attributes['instanceStart'].array; - expect(points).toEqual(Float32Array.from([0, 0, 0, 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); + }); }); -test('.line returns a BufferGeometry when there are no vertices', () => { - const path = new Path(PathType.Travel, undefined, undefined, undefined); +describe('.line', () => { + test('returns a BufferGeometry from the path', () => { + const path = new Path(PathType.Travel, undefined, undefined, undefined); - const result = path.line(); + path.addPoint(0, 0, 0); + path.addPoint(1, 2, 3); - expect(result).not.toBeNull(); - const points = result.attributes['instanceStart'].array; - expect(points.length).toEqual(0); + 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/path.ts b/src/path.ts index 077824fe..8284aba0 100644 --- a/src/path.ts +++ b/src/path.ts @@ -52,12 +52,17 @@ export class Path { return path; } - geometry(): BufferGeometry { + geometry(opts: { extrusionWidthOverride?: number; lineHeightOverride?: number } = {}): BufferGeometry { if (this._vertices.length < 3) { return new BufferGeometry(); } - return new ExtrusionGeometry(this.path(), this.extrusionWidth, this.lineHeight, 4); + return new ExtrusionGeometry( + this.path(), + opts.extrusionWidthOverride ?? this.extrusionWidth, + opts.lineHeightOverride ?? this.lineHeight, + 4 + ); } line(): LineSegmentsGeometry { diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index f5364a17..4efd93b9 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -73,7 +73,7 @@ export class WebGLPreview { renderExtrusion = true; renderTravel = false; renderTubes = false; - extrusionWidth = 0.6; + extrusionWidth?: number; lineWidth?: number; lineHeight?: number; startLayer?: number; @@ -135,7 +135,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; @@ -475,7 +475,9 @@ export class WebGLPreview { } this._geometries[color] ||= []; - this._geometries[color].push(path.geometry()); + this._geometries[color].push( + path.geometry({ extrusionWidthOverride: this.extrusionWidth, lineHeightOverride: this.lineHeight }) + ); }); } From b89e593a72fc48641b7ba7d2242b290dcc4ace35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 15 Oct 2024 23:32:33 -0400 Subject: [PATCH 08/14] Bring back the tolerance logic (#224) * Bring back the tolerance logic * Simplify the options --- src/__tests__/job.ts | 46 +++++++++++++++++++++++++++++++++++++++++--- src/job.ts | 34 ++++++++++++++++++++------------ src/webgl-preview.ts | 8 ++++++-- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/__tests__/job.ts b/src/__tests__/job.ts index 7968fa60..df92d1ef 100644 --- a/src/__tests__/job.ts +++ b/src/__tests__/job.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from 'vitest'; -import { Job, State } from '../job'; +import { Job, State, LayersIndexer } from '../job'; import { PathType, Path } from '../path'; test('it has an initial state', () => { @@ -96,7 +96,7 @@ describe('.layers', () => { expect(layers?.[0].length).toEqual(2); }); - test('travel paths moving z create a new layer', () => { + test('travel paths moving z above the default tolerance create a new layer', () => { const job = new Job(); append_path(job, PathType.Extrusion, [ @@ -105,7 +105,7 @@ describe('.layers', () => { ]); append_path(job, PathType.Travel, [ [5, 6, 0], - [5, 6, 1] + [5, 6, LayersIndexer.DEFAULT_TOLERANCE + 0.01] ]); const layers = job.layers; @@ -117,6 +117,46 @@ describe('.layers', () => { expect(layers?.[1].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].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].length).toEqual(2); + }); + test('multiple travels in a row are on the same layer', () => { const job = new Job(); diff --git a/src/job.ts b/src/job.ts index de372a45..6ede3239 100644 --- a/src/job.ts +++ b/src/job.ts @@ -24,13 +24,13 @@ export class Job { private indexers: Indexer[]; inprogressPath: Path | undefined; - constructor(state?: State) { + constructor(opts: { state?: State; minLayerThreshold?: number } = {}) { this.paths = []; - this.state = state || State.initial; + this.state = opts.state || State.initial; this.layersPaths = [[]]; this.indexers = [ new TravelTypeIndexer({ travel: this.travelPaths, extrusion: this.extrusionPaths }), - new LayersIndexer(this.layersPaths) + new LayersIndexer(this.layersPaths, opts.minLayerThreshold) ]; } @@ -61,7 +61,7 @@ export class Job { } isPlanar(): boolean { - return this.paths.find((path) => path.travelType === PathType.Extrusion && path.hasVerticalMoves()) === undefined; + return this.layersPaths !== null; } private indexPath(path: Path): void { @@ -84,7 +84,7 @@ export class Job { } class NonApplicableIndexer extends Error {} -class Indexer { +export class Indexer { protected indexes: Record | Path[][]; constructor(indexes: Record | Path[][]) { this.indexes = indexes; @@ -95,7 +95,7 @@ class Indexer { } } -class TravelTypeIndexer extends Indexer { +export class TravelTypeIndexer extends Indexer { protected declare indexes: Record; constructor(indexes: Record) { super(indexes); @@ -115,10 +115,13 @@ class NonPlanarPathError extends NonApplicableIndexer { super("Non-planar paths can't be indexed by layer"); } } -class LayersIndexer extends Indexer { +export class LayersIndexer extends Indexer { + static readonly DEFAULT_TOLERANCE = 0.05; protected declare indexes: Path[][]; - constructor(indexes: Path[][]) { + private tolerance: number; + constructor(indexes: Path[][], tolerance: number = LayersIndexer.DEFAULT_TOLERANCE) { super(indexes); + this.tolerance = tolerance; } sortIn(path: Path): void { @@ -129,10 +132,17 @@ class LayersIndexer extends Indexer { if (path.travelType === PathType.Extrusion) { this.lastLayer().push(path); } else { - if ( - path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] !== arr[2]) && - this.lastLayer().find((p) => p.travelType === PathType.Extrusion) - ) { + const verticalTravels = path.vertices + .map((_, i, arr) => { + if (i % 3 === 2 && arr[i] - arr[2] > this.tolerance) { + return arr[i] - arr[2]; + } + }) + .filter((z) => z !== undefined); + const hasVerticalTravel = verticalTravels.length > 0; + const hasExtrusions = this.lastLayer().find((p) => p.travelType === PathType.Extrusion); + + if (hasVerticalTravel && hasExtrusions) { this.createLayer(); } this.lastLayer().push(path); diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 4efd93b9..e579ae24 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -39,6 +39,7 @@ export type GCodePreviewOptions = { lineWidth?: number; lineHeight?: number; nonTravelMoves?: string[]; + minLayerThreshold?: number; renderExtrusion?: boolean; renderTravel?: boolean; startLayer?: number; @@ -61,6 +62,7 @@ export type GCodePreviewOptions = { }; export class WebGLPreview { + minLayerThreshold: number; /** * @deprecated Please use the `canvas` param instead. */ @@ -89,8 +91,8 @@ export class WebGLPreview { nonTravelmoves: string[] = []; disableGradient = false; + job: Job; interpreter = new Interpreter(); - job = new Job(); parser = new Parser(); // rendering @@ -118,6 +120,8 @@ export class WebGLPreview { private devGui?: DevGUI; constructor(opts: GCodePreviewOptions) { + this.minLayerThreshold = opts.minLayerThreshold ?? this.minLayerThreshold; + this.job = new Job({ minLayerThreshold: this.minLayerThreshold }); this.scene = new Scene(); this.scene.background = this._backgroundColor; if (opts.backgroundColor !== undefined) { @@ -366,7 +370,7 @@ export class WebGLPreview { clear(): void { this.resetState(); this.parser = new Parser(); - this.job = new Job(); + this.job = new Job({ minLayerThreshold: this.minLayerThreshold }); } // reset processing state From b7fffdcbfb05d1b05a245870fade24ff4c53ea40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 15 Oct 2024 23:32:52 -0400 Subject: [PATCH 09/14] wire-up-render-extrusion-and-render-tubes (#228) --- src/webgl-preview.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index e579ae24..e5328d10 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -469,6 +469,10 @@ export class WebGLPreview { } private renderGeometries(paths = this.job.extrusions): void { + if (!this.renderExtrusion || !this.renderTubes) { + return; + } + if (Object.keys(this._geometries).length === 0 && this.renderTubes) { let color: number; paths.forEach((path) => { From e9a50e2ca4c36df5db67d71aa0705fd48cb65b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Thu, 17 Oct 2024 19:22:41 -0400 Subject: [PATCH 10/14] Remove the code enum (#229) --- src/__tests__/interpreter.ts | 38 ++++++++++----------- src/gcode-parser.ts | 65 ++---------------------------------- src/interpreter.ts | 43 +++++++++++++----------- 3 files changed, 45 insertions(+), 101 deletions(-) diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts index 98fb1910..91f913ba 100644 --- a/src/__tests__/interpreter.ts +++ b/src/__tests__/interpreter.ts @@ -112,7 +112,7 @@ test('.G0 continues the path if the job has one', () => { job.state.z = 5; interpreter.execute([command1], job); - interpreter.G0(command2, job); + interpreter.g0(command2, job); expect(job.paths.length).toEqual(0); expect(job.inprogressPath?.vertices.length).toEqual(9); @@ -126,7 +126,7 @@ test(".G0 assigns the travel type if there's no extrusion", () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G0(command, job); + interpreter.g0(command, job); expect(job.paths.length).toEqual(0); expect(job.inprogressPath?.travelType).toEqual(PathType.Travel); @@ -137,7 +137,7 @@ test(".G0 assigns the extrusion type if there's extrusion", () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G0(command, job); + interpreter.g0(command, job); expect(job.paths.length).toEqual(0); expect(job.inprogressPath?.travelType).toEqual('Extrusion'); @@ -148,7 +148,7 @@ test('.G0 assigns the travel type if the extrusion is a retraction', () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G0(command, job); + interpreter.g0(command, job); expect(job.paths.length).toEqual(0); expect(job.inprogressPath?.travelType).toEqual('Travel'); @@ -159,7 +159,7 @@ test('.G0 assigns the travel type if the extrusion is a retraction', () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G0(command, job); + interpreter.g0(command, job); expect(job.paths.length).toEqual(0); expect(job.inprogressPath?.travelType).toEqual('Travel'); @@ -172,7 +172,7 @@ test('.G0 starts a new path if the travel type changes from Travel to Extrusion' const job = new Job(); interpreter.execute([command1], job); - interpreter.G0(command2, job); + interpreter.g0(command2, job); expect(job.paths.length).toEqual(1); expect(job.inprogressPath?.travelType).toEqual('Extrusion'); @@ -185,7 +185,7 @@ test('.G0 starts a new path if the travel type changes from Extrusion to Travel' const job = new Job(); interpreter.execute([command1], job); - interpreter.G0(command2, job); + interpreter.g0(command2, job); expect(job.paths.length).toEqual(1); expect(job.inprogressPath?.travelType).toEqual('Travel'); @@ -194,7 +194,7 @@ test('.G0 starts a new path if the travel type changes from Extrusion to Travel' test('.G1 is an alias to .G0', () => { const interpreter = new Interpreter(); - expect(interpreter.G1).toEqual(interpreter.G0); + expect(interpreter.g1).toEqual(interpreter.g0); }); test('.G20 sets the units to inches', () => { @@ -202,7 +202,7 @@ test('.G20 sets the units to inches', () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G20(command, job); + interpreter.g20(command, job); expect(job.state.units).toEqual('in'); }); @@ -212,7 +212,7 @@ test('.G21 sets the units to millimeters', () => { const interpreter = new Interpreter(); const job = new Job(); - interpreter.G21(command, job); + interpreter.g21(command, job); expect(job.state.units).toEqual('mm'); }); @@ -224,7 +224,7 @@ test('.g28 moves the state to the origin', () => { job.state.x = 3; job.state.y = 4; - interpreter.G28(command, job); + interpreter.g28(command, job); expect(job.state.x).toEqual(0); expect(job.state.y).toEqual(0); @@ -237,7 +237,7 @@ test('.t0 sets the tool to 0', () => { const job = new Job(); job.state.tool = 3; - interpreter.T0(command, job); + interpreter.t0(command, job); expect(job.state.tool).toEqual(0); }); @@ -248,7 +248,7 @@ test('.t1 sets the tool to 1', () => { const job = new Job(); job.state.tool = 3; - interpreter.T1(command, job); + interpreter.t1(command, job); expect(job.state.tool).toEqual(1); }); @@ -259,7 +259,7 @@ test('.t2 sets the tool to 2', () => { const job = new Job(); job.state.tool = 3; - interpreter.T2(command, job); + interpreter.t2(command, job); expect(job.state.tool).toEqual(2); }); @@ -270,7 +270,7 @@ test('.t3 sets the tool to 3', () => { const job = new Job(); job.state.tool = 3; - interpreter.T3(command, job); + interpreter.t3(command, job); expect(job.state.tool).toEqual(3); }); @@ -281,7 +281,7 @@ test('.t4 sets the tool to 4', () => { const job = new Job(); job.state.tool = 3; - interpreter.T4(command, job); + interpreter.t4(command, job); expect(job.state.tool).toEqual(4); }); @@ -292,7 +292,7 @@ test('.t5 sets the tool to 5', () => { const job = new Job(); job.state.tool = 3; - interpreter.T5(command, job); + interpreter.t5(command, job); expect(job.state.tool).toEqual(5); }); @@ -303,7 +303,7 @@ test('.t6 sets the tool to 6', () => { const job = new Job(); job.state.tool = 3; - interpreter.T6(command, job); + interpreter.t6(command, job); expect(job.state.tool).toEqual(6); }); @@ -314,7 +314,7 @@ test('.t7 sets the tool to 7', () => { const job = new Job(); job.state.tool = 3; - interpreter.T7(command, job); + interpreter.t7(command, job); expect(job.state.tool).toEqual(7); }); diff --git a/src/gcode-parser.ts b/src/gcode-parser.ts index 27320362..354cddb2 100644 --- a/src/gcode-parser.ts +++ b/src/gcode-parser.ts @@ -56,74 +56,13 @@ type singleLetter = | 'Z'; type CommandParams = { [key in singleLetter]?: number }; -export enum Code { - G0 = 'G0', - G1 = 'G1', - G2 = 'G2', - G3 = 'G3', - G20 = 'G20', - G21 = 'G21', - G28 = 'G28', - 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 - ) { - this.code = this.match(gcode); - } - - 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 'g21': - return Code.G21; - case 'g28': - return Code.G28; - 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 }; @@ -163,7 +102,7 @@ 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)); return new GCodeCommand(line, gcode, params, comment); } diff --git a/src/interpreter.ts b/src/interpreter.ts index 40523d04..44630b7c 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -1,19 +1,24 @@ import { Path, PathType } from './path'; -import { Code, GCodeCommand } from './gcode-parser'; +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 { commands.forEach((command) => { - if (command.code !== undefined) { - this[command.code](command, job); + if (command.gcode !== undefined) { + if (this[command.gcode] === undefined) { + return; + } + this[command.gcode](command, job); } }); return job; } - G0(command: GCodeCommand, job: Job): void { + g0(command: GCodeCommand, job: Job): void { const { x, y, z, e } = command.params; const { state } = job; @@ -31,14 +36,14 @@ export class Interpreter { currentPath.addPoint(state.x, state.y, state.z); } - G1 = this.G0; + g1 = this.g0; - G2(command: GCodeCommand, job: Job): void { + 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; + const cw = command.gcode === 'g2'; let currentPath = job.inprogressPath; const pathType = e ? PathType.Extrusion : PathType.Travel; @@ -128,44 +133,44 @@ export class Interpreter { currentPath.addPoint(state.x, state.y, state.z); } - G3 = this.G2; + g3 = this.g2; - G20(command: GCodeCommand, job: Job): void { + g20(command: GCodeCommand, job: Job): void { job.state.units = 'in'; } - G21(command: GCodeCommand, job: Job): void { + g21(command: GCodeCommand, job: Job): void { job.state.units = 'mm'; } - G28(command: GCodeCommand, job: Job): void { + g28(command: GCodeCommand, job: Job): void { job.state.x = 0; job.state.y = 0; job.state.z = 0; } - T0(command: GCodeCommand, job: Job): void { + t0(command: GCodeCommand, job: Job): void { job.state.tool = 0; } - T1(command: GCodeCommand, job: Job): void { + t1(command: GCodeCommand, job: Job): void { job.state.tool = 1; } - T2(command: GCodeCommand, job: Job): void { + t2(command: GCodeCommand, job: Job): void { job.state.tool = 2; } - T3(command: GCodeCommand, job: Job): void { + t3(command: GCodeCommand, job: Job): void { job.state.tool = 3; } - T4(command: GCodeCommand, job: Job): void { + t4(command: GCodeCommand, job: Job): void { job.state.tool = 4; } - T5(command: GCodeCommand, job: Job): void { + t5(command: GCodeCommand, job: Job): void { job.state.tool = 5; } - T6(command: GCodeCommand, job: Job): void { + t6(command: GCodeCommand, job: Job): void { job.state.tool = 6; } - T7(command: GCodeCommand, job: Job): void { + t7(command: GCodeCommand, job: Job): void { job.state.tool = 7; } From 4c761a8280a39e57be366c4046c10c981dd8f123 Mon Sep 17 00:00:00 2001 From: Remco Veldkamp Date: Fri, 18 Oct 2024 19:23:26 +0200 Subject: [PATCH 11/14] Remove 2 very old and unused files --- .gitpod.yml | 9 --------- line2-segments | 24 ------------------------ 2 files changed, 33 deletions(-) delete mode 100644 .gitpod.yml delete mode 100644 line2-segments 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/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 From 1b32b244c8494ea344b1c69947938b16b4d1556f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Fri, 18 Oct 2024 22:15:40 -0400 Subject: [PATCH 12/14] Bring back a representation of layers (#233) * Remove the code enum (#229) * Bring back a representation of layers * No need to expose isPlanar now * Make layers 100% progressive rendering ready * Layer number, height and z * fix the splitting logic --- demo/js/app.js | 14 +- src/__tests__/interpreter.ts | 378 ++++++++++++++++++----------------- src/__tests__/job.ts | 149 ++++++++++++-- src/interpreter.ts | 2 + src/job.ts | 103 ++++++---- src/webgl-preview.ts | 8 +- 6 files changed, 392 insertions(+), 262 deletions(-) diff --git a/demo/js/app.js b/demo/js/app.js index 9daf46b9..93281454 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -53,6 +53,7 @@ export const app = (window.app = createApp({ const updateUI = async () => { const { parser, + countLayers, extrusionColor, topLayerColor, lastSegmentColor, @@ -64,17 +65,16 @@ export const app = (window.app = createApp({ renderExtrusion, lineWidth, renderTubes, - extrusionWidth, - job + extrusionWidth } = preview; const { thumbnails } = parser.metadata; thumbnail.value = thumbnails['220x124']?.src; - layerCount.value = job.layers?.length; + layerCount.value = countLayers; const colors = extrusionColor instanceof Array ? extrusionColor : [extrusionColor]; const currentSettings = { - maxLayer: job.layers?.length, - endLayer: job.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 = job.layers?.length; + preview.endLayer = countLayers; }; const loadGCodeFromServer = async (filename) => { @@ -126,7 +126,7 @@ export const app = (window.app = createApp({ preview.render(); return; } - await preview.renderAnimated(Math.ceil(preview.job.layers?.length / 60)); + await preview.renderAnimated(Math.ceil(preview.countLayers / 60)); } else { preview.render(); } diff --git a/src/__tests__/interpreter.ts b/src/__tests__/interpreter.ts index 91f913ba..16cc044c 100644 --- a/src/__tests__/interpreter.ts +++ b/src/__tests__/interpreter.ts @@ -1,200 +1,202 @@ -import { test, expect } from 'vitest'; +import { test, expect, describe } 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(0); - expect(job.inprogressPath).not.toBeNull(); - expect(job.inprogressPath?.vertices.length).toEqual(6); - expect(job.inprogressPath?.vertices[0]).toEqual(0); - expect(job.inprogressPath?.vertices[1]).toEqual(0); - expect(job.inprogressPath?.vertices[2]).toEqual(0); - expect(job.inprogressPath?.vertices[3]).toEqual(1); - expect(job.inprogressPath?.vertices[4]).toEqual(2); - expect(job.inprogressPath?.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(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); +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); + }); }); -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); +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'); + }); - expect(job.paths.length).toEqual(0); - expect(job.inprogressPath?.vertices.length).toEqual(9); - expect(job.inprogressPath?.vertices[6]).toEqual(3); - expect(job.inprogressPath?.vertices[7]).toEqual(4); - expect(job.inprogressPath?.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(0); - expect(job.inprogressPath?.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(0); - expect(job.inprogressPath?.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(0); - expect(job.inprogressPath?.travelType).toEqual('Travel'); -}); - -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(0); - expect(job.inprogressPath?.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(1); - expect(job.inprogressPath?.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(1); - expect(job.inprogressPath?.travelType).toEqual('Travel'); -}); - -test('.G1 is an alias to .G0', () => { - const interpreter = new Interpreter(); + test('.G1 is an alias to .G0', () => { + const interpreter = new Interpreter(); - expect(interpreter.g1).toEqual(interpreter.g0); + expect(interpreter.g1).toEqual(interpreter.g0); + }); }); test('.G20 sets the units to inches', () => { diff --git a/src/__tests__/job.ts b/src/__tests__/job.ts index df92d1ef..152aaf06 100644 --- a/src/__tests__/job.ts +++ b/src/__tests__/job.ts @@ -73,7 +73,7 @@ describe('.layers', () => { [5, 6, 1] ]); - expect(job.layers).toEqual(null); + expect(job.layers).toEqual([]); }); test('paths without z changes are on the same layer', () => { @@ -92,29 +92,29 @@ describe('.layers', () => { expect(layers).not.toBeNull(); expect(layers).toBeInstanceOf(Array); - expect(layers?.length).toEqual(1); - expect(layers?.[0].length).toEqual(2); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); }); - test('travel paths moving z above the default tolerance create a new layer', () => { + 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.Travel, [ - [5, 6, 0], - [5, 6, LayersIndexer.DEFAULT_TOLERANCE + 0.01] + 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].length).toEqual(1); - expect(layers?.[1].length).toEqual(1); + 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', () => { @@ -133,8 +133,8 @@ describe('.layers', () => { expect(layers).not.toBeNull(); expect(layers).toBeInstanceOf(Array); - expect(layers?.length).toEqual(1); - expect(layers?.[0].length).toEqual(2); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); }); test('Tolerance can be set', () => { @@ -153,8 +153,8 @@ describe('.layers', () => { expect(layers).not.toBeNull(); expect(layers).toBeInstanceOf(Array); - expect(layers?.length).toEqual(1); - expect(layers?.[0].length).toEqual(2); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(2); }); test('multiple travels in a row are on the same layer', () => { @@ -181,9 +181,8 @@ describe('.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); + expect(layers.length).toEqual(1); + expect(layers[0].paths.length).toEqual(4); }); test('extrusions after travels are on the same layer', () => { @@ -214,9 +213,9 @@ describe('.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); + expect(layers.length).toEqual(2); + expect(layers[0].paths.length).toEqual(4); + expect(layers[1].paths.length).toEqual(1); }); }); @@ -280,8 +279,116 @@ describe('.travels', () => { }); }); -function append_path(job, travelType, points) { +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('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.resumeLastPath(); + + expect(job.inprogressPath).toBeUndefined(); + }); + + 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][]): Path { const path = new Path(travelType, 0.6, 0.2, job.state.tool); points.forEach((point: [number, number, number]) => path.addPoint(...point)); job.addPath(path); + return path; } diff --git a/src/interpreter.ts b/src/interpreter.ts index 44630b7c..80943929 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -6,6 +6,7 @@ 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) { @@ -14,6 +15,7 @@ export class Interpreter { this[command.gcode](command, job); } }); + job.finishPath(); return job; } diff --git a/src/job.ts b/src/job.ts index 6ede3239..7c51cf08 100644 --- a/src/job.ts +++ b/src/job.ts @@ -15,22 +15,35 @@ export class 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[]; + paths: Path[] = []; state: State; private travelPaths: Path[] = []; private extrusionPaths: Path[] = []; - private layersPaths: Path[][] | null; + private _layers: Layer[] = []; private indexers: Indexer[]; inprogressPath: Path | undefined; constructor(opts: { state?: State; minLayerThreshold?: number } = {}) { - this.paths = []; this.state = opts.state || State.initial; - this.layersPaths = [[]]; this.indexers = [ new TravelTypeIndexer({ travel: this.travelPaths, extrusion: this.extrusionPaths }), - new LayersIndexer(this.layersPaths, opts.minLayerThreshold) + new LayersIndexer(this._layers, opts.minLayerThreshold) ]; } @@ -42,8 +55,13 @@ export class Job { return this.travelPaths; } - get layers(): Path[][] | null { - return this.layersPaths; + get layers(): Layer[] { + return this._layers; + } + + addPath(path: Path): void { + this.paths.push(path); + this.indexPath(path); } finishPath(): void { @@ -52,16 +70,25 @@ export class Job { } if (this.inprogressPath.vertices.length > 0) { this.addPath(this.inprogressPath); + this.inprogressPath = undefined; } } - addPath(path: Path): void { - this.paths.push(path); - this.indexPath(path); + resumeLastPath(): void { + 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.layersPaths !== null; + return this.layers.length > 0; } private indexPath(path: Path): void { @@ -71,7 +98,7 @@ export class Job { } catch (e) { if (e instanceof NonApplicableIndexer) { if (e instanceof NonPlanarPathError) { - this.layersPaths = null; + this._layers = []; } const i = this.indexers.indexOf(indexer); this.indexers.splice(i, 1); @@ -85,8 +112,8 @@ export class Job { class NonApplicableIndexer extends Error {} export class Indexer { - protected indexes: Record | Path[][]; - constructor(indexes: Record | Path[][]) { + protected indexes: Record | Layer[]; + constructor(indexes: Record | Layer[]) { this.indexes = indexes; } sortIn(path: Path): void { @@ -117,52 +144,40 @@ class NonPlanarPathError extends NonApplicableIndexer { } export class LayersIndexer extends Indexer { static readonly DEFAULT_TOLERANCE = 0.05; - protected declare indexes: Path[][]; + protected declare indexes: Layer[]; private tolerance: number; - constructor(indexes: Path[][], tolerance: number = LayersIndexer.DEFAULT_TOLERANCE) { + 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 === 2 && arr[i] !== arr[2])) { + if ( + path.travelType === PathType.Extrusion && + path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] - arr[2] >= this.tolerance) + ) { throw new NonPlanarPathError(); } - if (path.travelType === PathType.Extrusion) { - this.lastLayer().push(path); - } else { - const verticalTravels = path.vertices - .map((_, i, arr) => { - if (i % 3 === 2 && arr[i] - arr[2] > this.tolerance) { - return arr[i] - arr[2]; - } - }) - .filter((z) => z !== undefined); - const hasVerticalTravel = verticalTravels.length > 0; - const hasExtrusions = this.lastLayer().find((p) => p.travelType === PathType.Extrusion); + if (this.indexes[this.indexes.length - 1] === undefined) { + this.createLayer(path.vertices[2]); + } - if (hasVerticalTravel && hasExtrusions) { - this.createLayer(); + if (path.travelType === PathType.Extrusion) { + if (path.vertices[2] - (this.lastLayer().z || 0) > this.tolerance) { + this.createLayer(path.vertices[2]); } - this.lastLayer().push(path); } + this.lastLayer().paths.push(path); } - private lastLayer(): Path[] { - if (this.indexes === undefined) { - this.indexes = [[]]; - } - - if (this.indexes[this.indexes.length - 1] === undefined) { - this.createLayer(); - return this.lastLayer(); - } + private lastLayer(): Layer { return this.indexes[this.indexes.length - 1]; } - private createLayer(): void { - const newLayer: Path[] = []; - this.indexes.push(newLayer); + private createLayer(z: number): void { + const layerNumber = this.indexes.length; + const height = z - this.lastLayer()?.z; + this.indexes.push(new Layer(this.indexes.length, [], layerNumber, height, z)); } } diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index e5328d10..5a79f519 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -91,7 +91,7 @@ export class WebGLPreview { nonTravelmoves: string[] = []; disableGradient = false; - job: Job; + private job: Job; interpreter = new Interpreter(); parser = new Parser(); @@ -254,6 +254,10 @@ export class WebGLPreview { this._lastSegmentColor = value !== undefined ? new Color(value) : undefined; } + get countLayers(): number { + return this.job.layers.length; + } + /** @internal */ animate(): void { this.animationFrameId = requestAnimationFrame(() => this.animate()); @@ -353,7 +357,7 @@ export class WebGLPreview { this.group = this.createGroup('layer' + this.renderLayerIndex); const endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers?.length - 1); - const pathsToRender = this.job.layers?.slice(this.renderLayerIndex, endIndex)?.flatMap((l) => l); + const pathsToRender = this.job.layers.slice(this.renderLayerIndex, endIndex)?.flatMap((l) => l.paths); this.renderGeometries(pathsToRender.filter((path) => path.travelType === 'Extrusion')); this.renderLines( From 88e6f51f6b3faaaf8e1145e205ee568c56d33555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 22 Oct 2024 12:46:16 -0400 Subject: [PATCH 13/14] Use clipping to render layers with `startLayer` and `endLayer` (#234) * Layer number, height and z * fix the splitting logic * partial rendering with clipping * Smoother plane movement * Accurate position of the planes * Clipping works * the clipping is done at the edges of the geometry * Tool indexer for color optimizations * Clipping that works with lines * Fix a bug that rendered only every other line * remove an accidentally duplicated test out of context * Layers work for vase mode * Progressive rendering * remove plane helpers * Single layer mode * Fix the geometries height * Polishing details * Polishing details * Test the tool indexer * Fix clipping and progressive rendering for small files --- demo/js/app.js | 11 +- demo/js/presets.js | 5 +- src/__tests__/job.ts | 69 +++++++++--- src/extrusion-geometry.ts | 2 +- src/job.ts | 43 ++++++-- src/path.ts | 8 +- src/webgl-preview.ts | 223 +++++++++++++++++++++++--------------- 7 files changed, 243 insertions(+), 118 deletions(-) diff --git a/demo/js/app.js b/demo/js/app.js index 93281454..aabfbc6d 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -126,7 +126,7 @@ export const app = (window.app = createApp({ preview.render(); return; } - await preview.renderAnimated(Math.ceil(preview.countLayers / 60)); + await preview.renderAnimated(2000); } else { preview.render(); } @@ -196,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; @@ -213,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 b9f6d7b4..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', @@ -44,6 +44,7 @@ export const presets = { file: 'gcodes/vase.gcode', lineWidth: 0, lineHeight: 0.4, + minLayerThreshold: 0.6, renderExtrusion: true, renderTubes: true, extrusionColor: ['rgb(84,74,187)'], @@ -61,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/src/__tests__/job.ts b/src/__tests__/job.ts index 152aaf06..d14743e4 100644 --- a/src/__tests__/job.ts +++ b/src/__tests__/job.ts @@ -217,6 +217,33 @@ describe('.layers', () => { 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', () => { @@ -279,6 +306,32 @@ describe('.travels', () => { }); }); +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(); @@ -363,18 +416,6 @@ describe('.resumeLastPath', () => { 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.resumeLastPath(); - - expect(job.inprogressPath).toBeUndefined(); - }); - test('the path is removed from indexes to not appear twice', () => { const job = new Job(); @@ -386,8 +427,8 @@ describe('.resumeLastPath', () => { }); }); -function append_path(job: Job, travelType, points: [number, number, number][]): Path { - const path = new Path(travelType, 0.6, 0.2, job.state.tool); +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/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/job.ts b/src/job.ts index 7c51cf08..c198d807 100644 --- a/src/job.ts +++ b/src/job.ts @@ -36,6 +36,7 @@ export class Job { private travelPaths: Path[] = []; private extrusionPaths: Path[] = []; private _layers: Layer[] = []; + private _toolPaths: Path[][] = []; private indexers: Indexer[]; inprogressPath: Path | undefined; @@ -43,7 +44,8 @@ export class Job { this.state = opts.state || State.initial; this.indexers = [ new TravelTypeIndexer({ travel: this.travelPaths, extrusion: this.extrusionPaths }), - new LayersIndexer(this._layers, opts.minLayerThreshold) + new LayersIndexer(this._layers, opts.minLayerThreshold), + new ToolIndexer(this._toolPaths) ]; } @@ -55,6 +57,10 @@ export class Job { return this.travelPaths; } + get toolPaths(): Path[][] { + return this._toolPaths; + } + get layers(): Layer[] { return this._layers; } @@ -75,6 +81,9 @@ export class Job { } 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) { @@ -112,8 +121,8 @@ export class Job { class NonApplicableIndexer extends Error {} export class Indexer { - protected indexes: Record | Layer[]; - constructor(indexes: Record | Layer[]) { + protected indexes: unknown; + constructor(indexes: unknown) { this.indexes = indexes; } sortIn(path: Path): void { @@ -122,7 +131,7 @@ export class Indexer { } } -export class TravelTypeIndexer extends Indexer { +class TravelTypeIndexer extends Indexer { protected declare indexes: Record; constructor(indexes: Record) { super(indexes); @@ -154,7 +163,7 @@ export class LayersIndexer extends Indexer { sortIn(path: Path): void { if ( path.travelType === PathType.Extrusion && - path.vertices.some((_, i, arr) => i % 3 === 2 && arr[i] - arr[2] >= this.tolerance) + path.vertices.some((_, i, arr) => i > 3 && i % 3 === 2 && Math.abs(arr[i] - arr[i - 3]) > this.tolerance) ) { throw new NonPlanarPathError(); } @@ -163,7 +172,10 @@ export class LayersIndexer extends Indexer { this.createLayer(path.vertices[2]); } - if (path.travelType === PathType.Extrusion) { + 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]); } @@ -177,7 +189,24 @@ export class LayersIndexer extends Indexer { private createLayer(z: number): void { const layerNumber = this.indexes.length; - const height = z - this.lastLayer()?.z; + 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 index 8284aba0..fc9340b1 100644 --- a/src/path.ts +++ b/src/path.ts @@ -66,7 +66,13 @@ export class Path { } line(): LineSegmentsGeometry { - return new LineSegmentsGeometry().setPositions(this.vertices); + 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 { diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 5a79f519..624ea2c4 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -2,6 +2,8 @@ import { Parser } from './gcode-parser'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.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'; @@ -9,6 +11,7 @@ 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, @@ -19,11 +22,14 @@ import { Euler, Fog, Group, + Material, MeshLambertMaterial, PerspectiveCamera, + Plane, PointLight, REVISION, Scene, + Vector3, WebGLRenderer } from 'three'; @@ -78,9 +84,9 @@ export class WebGLPreview { extrusionWidth?: number; lineWidth?: number; lineHeight?: number; - startLayer?: number; - endLayer?: number; - singleLayerMode = false; + _startLayer?: number; + _endLayer?: number; + _singleLayerMode = false; buildVolume?: BuildVolume; initialCameraPosition = [-100, 400, 450]; /** @@ -101,8 +107,10 @@ export class WebGLPreview { static readonly defaultExtrusionColor = new Color('hotpink'); private _extrusionColor: Color | Color[] = WebGLPreview.defaultExtrusionColor; private animationFrameId?: number; - private renderLayerIndex?: number; - 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); @@ -192,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; @@ -258,6 +267,52 @@ export class WebGLPreview { return this.job.layers.length; } + get startLayer(): number { + return this._startLayer; + } + set startLayer(value: number) { + this._startLayer = value; + if (this.countLayers > 1) { + if (value <= this.countLayers && value > 0) { + 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 = []; + } + } + } + + get endLayer(): number { + return this._endLayer; + } + set endLayer(value: number) { + this._endLayer = value; + if (this._singleLayerMode) { + this.startLayer = this._endLayer; + } + if (this.countLayers > 1) { + if (value <= this.countLayers && value > 0) { + 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; + } else { + this.startLayer = 1; + } + } + /** @internal */ animate(): void { this.animationFrameId = requestAnimationFrame(() => this.animate()); @@ -315,8 +370,7 @@ export class WebGLPreview { this.group = this.createGroup('allLayers'); this.initScene(); - this.renderGeometries(); - this.renderLines(); + this.renderPaths(); this.scene.add(this.group); this.renderer.render(this.scene, this.camera); @@ -325,27 +379,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; + this.renderPathIndex = 0; - if (this.job.layers === null) { - console.warn('Job is not planar'); + if (this.renderPathIndex >= this.job.paths.length - 1) { this.render(); - return; + } else { + return this.renderFrameLoop(pathCount > 0 ? Math.min(pathCount, this.job.paths.length) : 1); } - - return this.renderFrameLoop(layerCount > 0 ? layerCount : 1); } - private renderFrameLoop(layerCount: number): Promise { + private renderFrameLoop(pathCount: number): Promise { return new Promise((resolve) => { const loop = () => { - if (this.renderLayerIndex >= this.job.layers?.length - 1) { + if (this.renderPathIndex >= this.job.paths.length - 1) { resolve(); } else { - this.renderFrame(layerCount); + this.renderFrame(pathCount); requestAnimationFrame(loop); } }; @@ -353,20 +405,11 @@ export class WebGLPreview { }); } - private renderFrame(layerCount: number): void { - this.group = this.createGroup('layer' + this.renderLayerIndex); - - const endIndex = Math.min(this.renderLayerIndex + layerCount, this.job.layers?.length - 1); - const pathsToRender = this.job.layers.slice(this.renderLayerIndex, endIndex)?.flatMap((l) => l.paths); - - this.renderGeometries(pathsToRender.filter((path) => path.travelType === 'Extrusion')); - this.renderLines( - pathsToRender.filter((path) => path.travelType === 'Travel'), - pathsToRender.filter((path) => path.travelType === 'Extrusion') - ); - - this.renderLayerIndex = endIndex; - + 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); } @@ -381,9 +424,8 @@ export class WebGLPreview { private resetState(): void { this.startLayer = 1; this.endLayer = Infinity; - this.singleLayerMode = false; + this._singleLayerMode = false; this.devGui?.reset(); - this._geometries = {}; } resize(): void { @@ -438,82 +480,85 @@ export class WebGLPreview { }); } - private renderLines(travels = this.job.travels, extrusions = this.job.extrusions): void { + private renderPaths(endPathNumber: number = Infinity): void { + console.log('rendering paths'); if (this.renderTravel) { - const material = new LineMaterial({ color: this._travelColor, linewidth: this.lineWidth }); - this.disposables.push(material); + this.renderPathsAsLines(this.job.travels.slice(this.renderPathIndex, endPathNumber), this._travelColor); + } - travels.forEach((path) => { - const geometry = path.line(); - const line = new LineSegments2(geometry, material); - this.group?.add(line); + 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); + } }); } + } - if (this.renderExtrusion && !this.renderTubes) { - const lineMaterials = {} as Record; + 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 + }); - if (Array.isArray(this._extrusionColor)) { - this._extrusionColor.forEach((color, index) => { - lineMaterials[index] = new LineMaterial({ color, linewidth: this.lineWidth }); - }); - } else { - lineMaterials[0] = new LineMaterial({ - color: this._extrusionColor, - linewidth: this.lineWidth - }); + 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]); } + }); - extrusions.forEach((path) => { - const geometry = path.line(); - const line = new LineSegments2(geometry, lineMaterials[path.tool]); - this.group?.add(line); - }); - } - } + const geometry = new LineSegmentsGeometry().setPositions(lineVertices); + const line = new LineSegments2(geometry, material); - private renderGeometries(paths = this.job.extrusions): void { - if (!this.renderExtrusion || !this.renderTubes) { - return; - } + this.disposables.push(material); + this.disposables.push(geometry); + this.group?.add(line); + } - 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(); - } + private renderPathsAsTubes(paths: Path[], color: Color): void { + const colorNumber = Number(color.getHex()); + const geometries: BufferGeometry[] = []; - this._geometries[color] ||= []; - this._geometries[color].push( - path.geometry({ extrusionWidthOverride: this.extrusionWidth, lineHeightOverride: this.lineHeight }) - ); - }); - } + const material = new MeshLambertMaterial({ + color: colorNumber, + wireframe: this._wireframe, + clippingPlanes: this.clippingPlanes + }); - 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); + paths.forEach((path) => { + const geometry = path.geometry({ + extrusionWidthOverride: this.extrusionWidth, + lineHeightOverride: this.lineHeight }); - this._geometries[color] = []; - } - this._geometries = {}; - } + this.disposables.push(geometry); + geometries.push(geometry); + }); - private createBatchMesh(color: number): BatchedMesh { - const geometries = this._geometries[color]; - const material = new MeshLambertMaterial({ color: color, wireframe: this._wireframe }); + 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; } From 6de08a883e5755a9726d47b0eff83bd7bab50c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Wed, 23 Oct 2024 18:16:21 -0400 Subject: [PATCH 14/14] Fix startLayer --- src/webgl-preview.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 624ea2c4..6fa2b598 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -271,9 +271,10 @@ export class WebGLPreview { return this._startLayer; } set startLayer(value: number) { - this._startLayer = value; - if (this.countLayers > 1) { - if (value <= this.countLayers && value > 0) { + 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]; @@ -288,12 +289,12 @@ export class WebGLPreview { return this._endLayer; } set endLayer(value: number) { - this._endLayer = value; - if (this._singleLayerMode) { - this.startLayer = this._endLayer; - } - if (this.countLayers > 1) { - if (value <= this.countLayers && value > 0) { + 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]; @@ -308,8 +309,6 @@ export class WebGLPreview { this._singleLayerMode = value; if (value) { this.startLayer = this.endLayer - 1; - } else { - this.startLayer = 1; } }