gcodeToolpath.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import { describe, it, expect } from 'vitest';
  2. import * as THREE from 'three';
  3. import { parseGcodeToolpath, layersByFilament, filterLayersByType, ToolpathType } from '../../lib/gcodeToolpath';
  4. // @ts-expect-error -- vendored build output; typed by its sibling .d.ts, which
  5. // vitest's resolver does not pick up for a bare .js import.
  6. import { buildSegmentData, makeToolpath, TYPE_COLOR } from '../../lib/vendor/toolpathRenderer.js';
  7. /**
  8. * A minimal but realistic slice: two layers, an outer wall square and some
  9. * infill on each, with a travel between them. Written the way Bambu and Orca
  10. * actually emit -- relative extrusion, `;TYPE:` before each run, `;WIDTH:`,
  11. * and a bare Z move for the layer change.
  12. */
  13. const GCODE = `
  14. ;TYPE:Custom
  15. M83
  16. G1 Z0.2 F600
  17. ;TYPE:Outer wall
  18. ;WIDTH:0.42
  19. G1 X0 Y0 F1200
  20. G1 X10 Y0 E0.5
  21. G1 X10 Y10 E0.5
  22. G1 X0 Y10 E0.5
  23. G1 X0 Y0 E0.5
  24. ;TYPE:Sparse infill
  25. G1 X2 Y2 F9000
  26. G1 X8 Y8 E0.3
  27. G1 Z0.4 F600
  28. ;TYPE:Outer wall
  29. G1 X0 Y0 F1200
  30. G1 X10 Y0 E0.5
  31. G1 X10 Y10 E0.5
  32. ;TYPE:Support
  33. G1 X20 Y20 E0.4
  34. `;
  35. describe('parseGcodeToolpath', () => {
  36. const parsed = parseGcodeToolpath(GCODE);
  37. it('splits the file into layers in print order', () => {
  38. expect(parsed.layers.length).toBe(2);
  39. expect(parsed.layers[0].z).toBeCloseTo(0.2);
  40. expect(parsed.layers[1].z).toBeCloseTo(0.4);
  41. });
  42. it('counts extrusions and travels separately', () => {
  43. // 4 wall + 1 infill on layer 1; 2 wall + 1 support on layer 2.
  44. expect(parsed.segmentCount).toBe(8);
  45. // The two repositioning moves before a run, plus the initial Z lift.
  46. expect(parsed.travelCount).toBeGreaterThan(0);
  47. });
  48. it('classifies features from the ;TYPE: annotations', () => {
  49. const types = (layer: number) => {
  50. const p = parsed.layers[layer].paths;
  51. const out: number[] = [];
  52. for (let i = 0; i < p.length; i += 8) out.push(p[i + 3]);
  53. return out;
  54. };
  55. expect(types(0)).toContain(ToolpathType.wall);
  56. expect(types(0)).toContain(ToolpathType.sparseInfill);
  57. expect(types(1)).toContain(ToolpathType.support);
  58. });
  59. it('marks non-extruding moves as travel', () => {
  60. const p = parsed.layers[0].paths;
  61. const travels: number[] = [];
  62. for (let i = 0; i < p.length; i += 8) {
  63. if (p[i + 3] === ToolpathType.travel) travels.push(i);
  64. }
  65. expect(travels.length).toBeGreaterThan(0);
  66. });
  67. it('reads the extrusion width out of the file rather than assuming one', () => {
  68. expect(parsed.defaultWidth).toBeCloseTo(0.42);
  69. });
  70. it('handles absolute extrusion as well as relative', () => {
  71. // Without M83, E values are cumulative; treating them as relative would
  72. // make every move after the first look like a huge extrusion, and a
  73. // retraction would read as an extrusion rather than a travel.
  74. const absolute = parseGcodeToolpath(`
  75. ;TYPE:Outer wall
  76. M82
  77. G1 X0 Y0 Z0.2
  78. G1 X10 Y0 E1.0
  79. G1 X10 Y10 E2.0
  80. G1 X0 Y10 E1.5
  81. `);
  82. // Two extrusions (E rising), then one retraction (E falling) as a travel.
  83. expect(absolute.segmentCount).toBe(2);
  84. expect(absolute.travelCount).toBeGreaterThan(0);
  85. });
  86. it('keeps geometry for an unrecognised feature name', () => {
  87. // A type we do not know must never drop the move -- a hole in the preview
  88. // is far worse than a wrongly coloured segment.
  89. const unknown = parseGcodeToolpath(`
  90. M83
  91. ;TYPE:Some Future Feature
  92. G1 X0 Y0 Z0.2
  93. G1 X5 Y5 E0.4
  94. `);
  95. expect(unknown.segmentCount).toBe(1);
  96. });
  97. it('reports the model bounds', () => {
  98. expect(parsed.bounds).not.toBeNull();
  99. expect(parsed.bounds!.max[0]).toBeCloseTo(20);
  100. expect(parsed.bounds!.max[1]).toBeCloseTo(20);
  101. });
  102. it('returns nothing rather than throwing on a file with no moves', () => {
  103. const empty = parseGcodeToolpath('; just a comment\nM104 S200\n');
  104. expect(empty.layers).toEqual([]);
  105. expect(empty.bounds).toBeNull();
  106. });
  107. });
  108. describe('the vendored libvgcode renderer accepts our parse', () => {
  109. const parsed = parseGcodeToolpath(GCODE);
  110. it('builds segment data from the parsed layers', () => {
  111. const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
  112. expect(data.nSeg).toBe(parsed.segmentCount);
  113. expect(data.layerCount).toBe(2);
  114. expect(data.hasNaN).toBe(false);
  115. expect(data.bbox).not.toBeNull();
  116. });
  117. it('carries travel moves through as a separate stream', () => {
  118. const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
  119. expect(data.nTrav).toBe(parsed.travelCount);
  120. });
  121. it('keeps per-vertex feature, width and layer metadata', () => {
  122. const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
  123. expect(data.meta.vType.length).toBe(data.nV);
  124. expect(Array.from(data.meta.vType)).toContain(ToolpathType.wall);
  125. expect(Array.from(data.meta.vLayer)).toContain(1);
  126. // Width came from ;WIDTH:, not the fallback.
  127. expect(Array.from(data.meta.vWidth).some((w) => Math.abs(w - 0.42) < 1e-6)).toBe(true);
  128. });
  129. it('builds a three.js mesh on our own three version', () => {
  130. // The whole reason this renderer is usable: it imports no three and takes
  131. // the namespace as an argument, so it runs on our 0.181 rather than the
  132. // 0.160 its own package pins.
  133. const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
  134. const handle = makeToolpath(THREE, data);
  135. expect(handle.mesh).toBeInstanceOf(THREE.Mesh);
  136. expect(handle.nSeg).toBe(parsed.segmentCount);
  137. expect(handle.layerCount).toBe(2);
  138. const geometry = handle.mesh.geometry as THREE.BufferGeometry;
  139. // The drawn primitive is libvgcode's diamond cross-section: 8 triangles,
  140. // 24 indices, instanced once per segment. That single indexed draw is what
  141. // keeps a million-segment print to one call.
  142. expect(geometry.index?.count).toBe(24);
  143. expect(geometry.attributes.seg_id_a_u.count).toBe(parsed.segmentCount);
  144. expect(geometry.attributes.seg_layer_u.count).toBe(parsed.segmentCount);
  145. handle.dispose();
  146. });
  147. it('exposes layer-range and travel controls', () => {
  148. const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
  149. const handle = makeToolpath(THREE, data);
  150. expect(() => handle.setLayerRange(0, 0)).not.toThrow();
  151. expect(() => handle.setTravelVisible(true)).not.toThrow();
  152. expect(handle.travLines.visible).toBe(true);
  153. expect(() => handle.setTravelVisible(false)).not.toThrow();
  154. expect(handle.travLines.visible).toBe(false);
  155. handle.dispose();
  156. });
  157. it('ships the libvgcode feature palette', () => {
  158. // Sanity that the vendored module is the real thing and not a stub.
  159. expect(TYPE_COLOR[ToolpathType.wall]).toHaveLength(3);
  160. expect(TYPE_COLOR[ToolpathType.support]).toHaveLength(3);
  161. });
  162. });
  163. /**
  164. * BambuStudio's dialect. It does not emit any of the annotations the
  165. * OrcaSlicer/PrusaSlicer lineage uses -- no `;TYPE:`, no `;WIDTH:`, no
  166. * `;LAYER_CHANGE` -- and reading only those rendered a real Bambu file as one
  167. * undifferentiated colour with a layer per travel Z-hop (52 layers came out as
  168. * 23,165). Taken from an actual sliced plate.
  169. */
  170. const BAMBU_GCODE = `
  171. M83
  172. ; CHANGE_LAYER
  173. ; Z_HEIGHT: 0.2
  174. ; LINE_WIDTH: 0.42
  175. ; FEATURE: Outer wall
  176. G1 X10 Y10 Z0.2 F600
  177. G1 X20 Y10 E0.5
  178. G1 X20 Y20 E0.5
  179. ; FEATURE: Sparse infill
  180. G1 X12 Y12 F9000
  181. G1 X18 Y18 E0.3
  182. ; a travel Z-hop, which must not start a layer
  183. G1 Z0.6 F600
  184. G1 X30 Y30 F9000
  185. G1 Z0.2 F600
  186. ; FEATURE: Support
  187. G1 X31 Y31 E0.2
  188. ; CHANGE_LAYER
  189. ; Z_HEIGHT: 0.36
  190. ; FEATURE: Outer wall
  191. G1 X10 Y10 Z0.36 F600
  192. G1 X20 Y10 E0.5
  193. `;
  194. describe('parseGcodeToolpath — BambuStudio dialect', () => {
  195. const parsed = parseGcodeToolpath(BAMBU_GCODE);
  196. it('reads features from "; FEATURE:" rather than ";TYPE:"', () => {
  197. const allTypes = parsed.layers.flatMap((layer) => {
  198. const out: number[] = [];
  199. for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
  200. return out;
  201. });
  202. expect(allTypes).toContain(ToolpathType.sparseInfill);
  203. expect(allTypes).toContain(ToolpathType.support);
  204. // Everything falling back to `wall` is the signature of the dialect bug.
  205. expect(new Set(allTypes).size).toBeGreaterThan(2);
  206. });
  207. it('uses the explicit layer markers', () => {
  208. expect(parsed.layers.length).toBe(2);
  209. expect(parsed.layers[1].z).toBeCloseTo(0.36);
  210. });
  211. it('does not start a layer on a travel Z-hop', () => {
  212. // The hop to Z0.6 and back sits inside layer one; splitting there is what
  213. // multiplied the layer count by four hundred.
  214. const firstLayerTypes: number[] = [];
  215. const p = parsed.layers[0].paths;
  216. for (let i = 0; i < p.length; i += 8) firstLayerTypes.push(p[i + 3]);
  217. expect(firstLayerTypes).toContain(ToolpathType.support);
  218. });
  219. it('reads the width from "; LINE_WIDTH:"', () => {
  220. expect(parsed.defaultWidth).toBeCloseTo(0.42);
  221. });
  222. it('takes the typical width, not the widest', () => {
  223. // Widths in a real file span a 0.09 gap fill to a 1.0 purge line; the max
  224. // made every fallback segment absurdly fat.
  225. const mixed = parseGcodeToolpath(`
  226. M83
  227. ; FEATURE: Outer wall
  228. ; LINE_WIDTH: 0.42
  229. G1 X0 Y0 Z0.2
  230. G1 X10 Y0 E0.5
  231. G1 X10 Y10 E0.5
  232. ; LINE_WIDTH: 1.0
  233. G1 X0 Y10 E0.5
  234. `);
  235. expect(mixed.defaultWidth).toBeCloseTo(0.42);
  236. });
  237. it('does not draw a phantom segment from the origin', () => {
  238. // Position is unknown until the first move sets it; extruding from (0,0,0)
  239. // drew a stray line across the bed.
  240. const p = parsed.layers[0].paths;
  241. let touchesOrigin = false;
  242. for (let i = 0; i < p.length; i += 8) {
  243. if (p[i] === 0 && p[i + 1] === 0 && p[i + 3] !== ToolpathType.travel) touchesOrigin = true;
  244. }
  245. expect(touchesOrigin).toBe(false);
  246. });
  247. });
  248. describe('arc moves and filament tracking', () => {
  249. // BambuStudio has arc fitting on by default. A real plate carried 706
  250. // extruding G2/G3 moves against ~7800 linear ones, and dropping them left
  251. // holes through curved walls and tree supports -- the "huge gaps in the
  252. // support structure" this was reported as.
  253. const ARC_GCODE = `
  254. M83
  255. ; FEATURE: Outer wall
  256. ; LINE_WIDTH: 0.42
  257. G1 X10 Y0 Z0.2 F600
  258. G3 X0 Y10 I-10 J0 E1.0
  259. `;
  260. it('interpolates an arc into chords rather than dropping it', () => {
  261. const parsed = parseGcodeToolpath(ARC_GCODE);
  262. // A quarter circle of radius 10 at a 0.02mm chord tolerance is many
  263. // segments; the point is that it is neither 0 nor 1.
  264. expect(parsed.segmentCount).toBeGreaterThan(5);
  265. });
  266. it('keeps every interpolated chord on the arc', () => {
  267. const parsed = parseGcodeToolpath(ARC_GCODE);
  268. const centre = { x: 0, y: 0 };
  269. for (const layer of parsed.layers) {
  270. for (let i = 0; i < layer.paths.length; i += 8) {
  271. if (layer.paths[i + 3] === ToolpathType.travel) continue;
  272. const r = Math.hypot(layer.paths[i + 4] - centre.x, layer.paths[i + 5] - centre.y);
  273. // Every endpoint sits on the radius, within the chord tolerance.
  274. expect(Math.abs(r - 10)).toBeLessThan(0.1);
  275. }
  276. }
  277. });
  278. it('treats an arc with no X or Y as the helical travel lift it is', () => {
  279. // "G3 Z0.4 I1.2 J0 P1" is BambuStudio lifting the nozzle in a spiral. It
  280. // extrudes nothing and must not be mistaken for geometry.
  281. const parsed = parseGcodeToolpath(`
  282. M83
  283. ; FEATURE: Outer wall
  284. G1 X10 Y10 Z0.2 F600
  285. G1 X20 Y10 E0.5
  286. G3 Z0.6 I1.217 J0 P1 F60000
  287. `);
  288. expect(parsed.segmentCount).toBe(1);
  289. expect(parsed.travelCount).toBeGreaterThan(1);
  290. });
  291. it('does not mistake G20 or G28 for an arc', () => {
  292. const parsed = parseGcodeToolpath(`
  293. M83
  294. G21
  295. G28
  296. ; FEATURE: Outer wall
  297. G1 X10 Y10 Z0.2
  298. G1 X20 Y10 E0.5
  299. `);
  300. expect(parsed.segmentCount).toBe(1);
  301. });
  302. it('tracks the active filament across tool changes', () => {
  303. const parsed = parseGcodeToolpath(`
  304. M83
  305. ; FEATURE: Outer wall
  306. T0
  307. G1 X10 Y10 Z0.2 F600
  308. G1 X20 Y10 E0.5
  309. T1
  310. G1 X20 Y20 E0.5
  311. `);
  312. const tools: number[] = [];
  313. for (const layer of parsed.layers) {
  314. for (let i = 0; i < layer.paths.length; i += 8) {
  315. if (layer.paths[i + 3] !== ToolpathType.travel) tools.push(layer.paths[i + 7]);
  316. }
  317. }
  318. expect(tools).toContain(0);
  319. expect(tools).toContain(1);
  320. });
  321. it("ignores BambuStudio's sentinel tool numbers", () => {
  322. // T65535 / T65279 bracket the slicer's own bookkeeping and are not
  323. // filaments; treating them as such would key colours off a nonsense slot.
  324. const parsed = parseGcodeToolpath(`
  325. M83
  326. ; FEATURE: Outer wall
  327. T0
  328. G1 X10 Y10 Z0.2 F600
  329. T65535
  330. G1 X20 Y10 E0.5
  331. `);
  332. for (const layer of parsed.layers) {
  333. for (let i = 0; i < layer.paths.length; i += 8) {
  334. expect(layer.paths[i + 7]).toBeLessThanOrEqual(15);
  335. }
  336. }
  337. });
  338. it('re-keys types to filaments for the filament-coloured view', () => {
  339. const parsed = parseGcodeToolpath(`
  340. M83
  341. ; FEATURE: Support
  342. T1
  343. G1 X10 Y10 Z0.2 F600
  344. G1 X20 Y10 E0.5
  345. `);
  346. const recoloured = layersByFilament(parsed.layers);
  347. const typeOf = (layers: typeof parsed.layers) => {
  348. for (const layer of layers) {
  349. for (let i = 0; i < layer.paths.length; i += 8) {
  350. if (layer.paths[i + 3] !== ToolpathType.travel) return layer.paths[i + 3];
  351. }
  352. }
  353. return -1;
  354. };
  355. expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
  356. // Filament 1 becomes type 2 -- offset by one so slot 0 cannot collide
  357. // with the travel index.
  358. expect(typeOf(recoloured)).toBe(2);
  359. // The original must be untouched: both colourings are held at once.
  360. expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
  361. });
  362. });
  363. describe('hiding a feature or filament', () => {
  364. const GCODE = `
  365. M83
  366. ; LINE_WIDTH: 0.42
  367. ; FEATURE: Outer wall
  368. T0
  369. G1 X10 Y10 Z0.2 F600
  370. G1 X20 Y10 E0.5
  371. ; FEATURE: Support
  372. T1
  373. G1 X30 Y10 E0.5
  374. G1 X40 Y10 E0.5
  375. `;
  376. const typesOf = (layers: ReturnType<typeof parseGcodeToolpath>['layers']) => {
  377. const out: number[] = [];
  378. for (const layer of layers) {
  379. for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
  380. }
  381. return out;
  382. };
  383. it('removes the hidden feature and keeps the rest', () => {
  384. const parsed = parseGcodeToolpath(GCODE);
  385. const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.support]));
  386. expect(typesOf(filtered)).not.toContain(ToolpathType.support);
  387. expect(typesOf(filtered)).toContain(ToolpathType.wall);
  388. });
  389. it('keeps emptied layers so the range slider does not renumber', () => {
  390. // Dropping a layer that the filter emptied would shift every layer above
  391. // it, and the slider would then point at the wrong height.
  392. const parsed = parseGcodeToolpath(GCODE);
  393. const everything = new Set(typesOf(parsed.layers));
  394. const filtered = filterLayersByType(parsed.layers, everything);
  395. expect(filtered.length).toBe(parsed.layers.length);
  396. expect(typesOf(filtered)).toEqual([]);
  397. });
  398. it('keeps each kept record paired with its own width', () => {
  399. // The widths array is indexed in step with the records; dropping one
  400. // without dropping its width would smear widths across the rest.
  401. const parsed = parseGcodeToolpath(GCODE);
  402. const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.travel]));
  403. for (const layer of filtered) {
  404. expect(layer.widths.length).toBe(layer.paths.length / 8);
  405. }
  406. });
  407. it('hides by filament once the types are re-keyed', () => {
  408. const parsed = parseGcodeToolpath(GCODE);
  409. const byFilament = layersByFilament(parsed.layers);
  410. // Filament 1 is keyed as type 2.
  411. const filtered = filterLayersByType(byFilament, new Set([2]));
  412. expect(typesOf(filtered)).not.toContain(2);
  413. expect(typesOf(filtered)).toContain(1);
  414. });
  415. it('returns the input untouched when nothing is hidden', () => {
  416. const parsed = parseGcodeToolpath(GCODE);
  417. expect(filterLayersByType(parsed.layers, new Set())).toBe(parsed.layers);
  418. });
  419. });