import { describe, it, expect } from 'vitest'; import * as THREE from 'three'; import { parseGcodeToolpath, layersByFilament, filterLayersByType, ToolpathType } from '../../lib/gcodeToolpath'; // @ts-expect-error -- vendored build output; typed by its sibling .d.ts, which // vitest's resolver does not pick up for a bare .js import. import { buildSegmentData, makeToolpath, TYPE_COLOR } from '../../lib/vendor/toolpathRenderer.js'; /** * A minimal but realistic slice: two layers, an outer wall square and some * infill on each, with a travel between them. Written the way Bambu and Orca * actually emit -- relative extrusion, `;TYPE:` before each run, `;WIDTH:`, * and a bare Z move for the layer change. */ const GCODE = ` ;TYPE:Custom M83 G1 Z0.2 F600 ;TYPE:Outer wall ;WIDTH:0.42 G1 X0 Y0 F1200 G1 X10 Y0 E0.5 G1 X10 Y10 E0.5 G1 X0 Y10 E0.5 G1 X0 Y0 E0.5 ;TYPE:Sparse infill G1 X2 Y2 F9000 G1 X8 Y8 E0.3 G1 Z0.4 F600 ;TYPE:Outer wall G1 X0 Y0 F1200 G1 X10 Y0 E0.5 G1 X10 Y10 E0.5 ;TYPE:Support G1 X20 Y20 E0.4 `; describe('parseGcodeToolpath', () => { const parsed = parseGcodeToolpath(GCODE); it('splits the file into layers in print order', () => { expect(parsed.layers.length).toBe(2); expect(parsed.layers[0].z).toBeCloseTo(0.2); expect(parsed.layers[1].z).toBeCloseTo(0.4); }); it('counts extrusions and travels separately', () => { // 4 wall + 1 infill on layer 1; 2 wall + 1 support on layer 2. expect(parsed.segmentCount).toBe(8); // The two repositioning moves before a run, plus the initial Z lift. expect(parsed.travelCount).toBeGreaterThan(0); }); it('classifies features from the ;TYPE: annotations', () => { const types = (layer: number) => { const p = parsed.layers[layer].paths; const out: number[] = []; for (let i = 0; i < p.length; i += 8) out.push(p[i + 3]); return out; }; expect(types(0)).toContain(ToolpathType.wall); expect(types(0)).toContain(ToolpathType.sparseInfill); expect(types(1)).toContain(ToolpathType.support); }); it('marks non-extruding moves as travel', () => { const p = parsed.layers[0].paths; const travels: number[] = []; for (let i = 0; i < p.length; i += 8) { if (p[i + 3] === ToolpathType.travel) travels.push(i); } expect(travels.length).toBeGreaterThan(0); }); it('reads the extrusion width out of the file rather than assuming one', () => { expect(parsed.defaultWidth).toBeCloseTo(0.42); }); it('handles absolute extrusion as well as relative', () => { // Without M83, E values are cumulative; treating them as relative would // make every move after the first look like a huge extrusion, and a // retraction would read as an extrusion rather than a travel. const absolute = parseGcodeToolpath(` ;TYPE:Outer wall M82 G1 X0 Y0 Z0.2 G1 X10 Y0 E1.0 G1 X10 Y10 E2.0 G1 X0 Y10 E1.5 `); // Two extrusions (E rising), then one retraction (E falling) as a travel. expect(absolute.segmentCount).toBe(2); expect(absolute.travelCount).toBeGreaterThan(0); }); it('keeps geometry for an unrecognised feature name', () => { // A type we do not know must never drop the move -- a hole in the preview // is far worse than a wrongly coloured segment. const unknown = parseGcodeToolpath(` M83 ;TYPE:Some Future Feature G1 X0 Y0 Z0.2 G1 X5 Y5 E0.4 `); expect(unknown.segmentCount).toBe(1); }); it('reports the model bounds', () => { expect(parsed.bounds).not.toBeNull(); expect(parsed.bounds!.max[0]).toBeCloseTo(20); expect(parsed.bounds!.max[1]).toBeCloseTo(20); }); it('returns nothing rather than throwing on a file with no moves', () => { const empty = parseGcodeToolpath('; just a comment\nM104 S200\n'); expect(empty.layers).toEqual([]); expect(empty.bounds).toBeNull(); }); }); describe('the vendored libvgcode renderer accepts our parse', () => { const parsed = parseGcodeToolpath(GCODE); it('builds segment data from the parsed layers', () => { const data = buildSegmentData(parsed.layers, parsed.defaultWidth); expect(data.nSeg).toBe(parsed.segmentCount); expect(data.layerCount).toBe(2); expect(data.hasNaN).toBe(false); expect(data.bbox).not.toBeNull(); }); it('carries travel moves through as a separate stream', () => { const data = buildSegmentData(parsed.layers, parsed.defaultWidth); expect(data.nTrav).toBe(parsed.travelCount); }); it('keeps per-vertex feature, width and layer metadata', () => { const data = buildSegmentData(parsed.layers, parsed.defaultWidth); expect(data.meta.vType.length).toBe(data.nV); expect(Array.from(data.meta.vType)).toContain(ToolpathType.wall); expect(Array.from(data.meta.vLayer)).toContain(1); // Width came from ;WIDTH:, not the fallback. expect(Array.from(data.meta.vWidth).some((w) => Math.abs(w - 0.42) < 1e-6)).toBe(true); }); it('builds a three.js mesh on our own three version', () => { // The whole reason this renderer is usable: it imports no three and takes // the namespace as an argument, so it runs on our 0.181 rather than the // 0.160 its own package pins. const data = buildSegmentData(parsed.layers, parsed.defaultWidth); const handle = makeToolpath(THREE, data); expect(handle.mesh).toBeInstanceOf(THREE.Mesh); expect(handle.nSeg).toBe(parsed.segmentCount); expect(handle.layerCount).toBe(2); const geometry = handle.mesh.geometry as THREE.BufferGeometry; // The drawn primitive is libvgcode's diamond cross-section: 8 triangles, // 24 indices, instanced once per segment. That single indexed draw is what // keeps a million-segment print to one call. expect(geometry.index?.count).toBe(24); expect(geometry.attributes.seg_id_a_u.count).toBe(parsed.segmentCount); expect(geometry.attributes.seg_layer_u.count).toBe(parsed.segmentCount); handle.dispose(); }); it('exposes layer-range and travel controls', () => { const data = buildSegmentData(parsed.layers, parsed.defaultWidth); const handle = makeToolpath(THREE, data); expect(() => handle.setLayerRange(0, 0)).not.toThrow(); expect(() => handle.setTravelVisible(true)).not.toThrow(); expect(handle.travLines.visible).toBe(true); expect(() => handle.setTravelVisible(false)).not.toThrow(); expect(handle.travLines.visible).toBe(false); handle.dispose(); }); it('ships the libvgcode feature palette', () => { // Sanity that the vendored module is the real thing and not a stub. expect(TYPE_COLOR[ToolpathType.wall]).toHaveLength(3); expect(TYPE_COLOR[ToolpathType.support]).toHaveLength(3); }); }); /** * BambuStudio's dialect. It does not emit any of the annotations the * OrcaSlicer/PrusaSlicer lineage uses -- no `;TYPE:`, no `;WIDTH:`, no * `;LAYER_CHANGE` -- and reading only those rendered a real Bambu file as one * undifferentiated colour with a layer per travel Z-hop (52 layers came out as * 23,165). Taken from an actual sliced plate. */ const BAMBU_GCODE = ` M83 ; CHANGE_LAYER ; Z_HEIGHT: 0.2 ; LINE_WIDTH: 0.42 ; FEATURE: Outer wall G1 X10 Y10 Z0.2 F600 G1 X20 Y10 E0.5 G1 X20 Y20 E0.5 ; FEATURE: Sparse infill G1 X12 Y12 F9000 G1 X18 Y18 E0.3 ; a travel Z-hop, which must not start a layer G1 Z0.6 F600 G1 X30 Y30 F9000 G1 Z0.2 F600 ; FEATURE: Support G1 X31 Y31 E0.2 ; CHANGE_LAYER ; Z_HEIGHT: 0.36 ; FEATURE: Outer wall G1 X10 Y10 Z0.36 F600 G1 X20 Y10 E0.5 `; describe('parseGcodeToolpath — BambuStudio dialect', () => { const parsed = parseGcodeToolpath(BAMBU_GCODE); it('reads features from "; FEATURE:" rather than ";TYPE:"', () => { const allTypes = parsed.layers.flatMap((layer) => { const out: number[] = []; for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]); return out; }); expect(allTypes).toContain(ToolpathType.sparseInfill); expect(allTypes).toContain(ToolpathType.support); // Everything falling back to `wall` is the signature of the dialect bug. expect(new Set(allTypes).size).toBeGreaterThan(2); }); it('uses the explicit layer markers', () => { expect(parsed.layers.length).toBe(2); expect(parsed.layers[1].z).toBeCloseTo(0.36); }); it('does not start a layer on a travel Z-hop', () => { // The hop to Z0.6 and back sits inside layer one; splitting there is what // multiplied the layer count by four hundred. const firstLayerTypes: number[] = []; const p = parsed.layers[0].paths; for (let i = 0; i < p.length; i += 8) firstLayerTypes.push(p[i + 3]); expect(firstLayerTypes).toContain(ToolpathType.support); }); it('reads the width from "; LINE_WIDTH:"', () => { expect(parsed.defaultWidth).toBeCloseTo(0.42); }); it('takes the typical width, not the widest', () => { // Widths in a real file span a 0.09 gap fill to a 1.0 purge line; the max // made every fallback segment absurdly fat. const mixed = parseGcodeToolpath(` M83 ; FEATURE: Outer wall ; LINE_WIDTH: 0.42 G1 X0 Y0 Z0.2 G1 X10 Y0 E0.5 G1 X10 Y10 E0.5 ; LINE_WIDTH: 1.0 G1 X0 Y10 E0.5 `); expect(mixed.defaultWidth).toBeCloseTo(0.42); }); it('does not draw a phantom segment from the origin', () => { // Position is unknown until the first move sets it; extruding from (0,0,0) // drew a stray line across the bed. const p = parsed.layers[0].paths; let touchesOrigin = false; for (let i = 0; i < p.length; i += 8) { if (p[i] === 0 && p[i + 1] === 0 && p[i + 3] !== ToolpathType.travel) touchesOrigin = true; } expect(touchesOrigin).toBe(false); }); }); describe('arc moves and filament tracking', () => { // BambuStudio has arc fitting on by default. A real plate carried 706 // extruding G2/G3 moves against ~7800 linear ones, and dropping them left // holes through curved walls and tree supports -- the "huge gaps in the // support structure" this was reported as. const ARC_GCODE = ` M83 ; FEATURE: Outer wall ; LINE_WIDTH: 0.42 G1 X10 Y0 Z0.2 F600 G3 X0 Y10 I-10 J0 E1.0 `; it('interpolates an arc into chords rather than dropping it', () => { const parsed = parseGcodeToolpath(ARC_GCODE); // A quarter circle of radius 10 at a 0.02mm chord tolerance is many // segments; the point is that it is neither 0 nor 1. expect(parsed.segmentCount).toBeGreaterThan(5); }); it('keeps every interpolated chord on the arc', () => { const parsed = parseGcodeToolpath(ARC_GCODE); const centre = { x: 0, y: 0 }; for (const layer of parsed.layers) { for (let i = 0; i < layer.paths.length; i += 8) { if (layer.paths[i + 3] === ToolpathType.travel) continue; const r = Math.hypot(layer.paths[i + 4] - centre.x, layer.paths[i + 5] - centre.y); // Every endpoint sits on the radius, within the chord tolerance. expect(Math.abs(r - 10)).toBeLessThan(0.1); } } }); it('treats an arc with no X or Y as the helical travel lift it is', () => { // "G3 Z0.4 I1.2 J0 P1" is BambuStudio lifting the nozzle in a spiral. It // extrudes nothing and must not be mistaken for geometry. const parsed = parseGcodeToolpath(` M83 ; FEATURE: Outer wall G1 X10 Y10 Z0.2 F600 G1 X20 Y10 E0.5 G3 Z0.6 I1.217 J0 P1 F60000 `); expect(parsed.segmentCount).toBe(1); expect(parsed.travelCount).toBeGreaterThan(1); }); it('does not mistake G20 or G28 for an arc', () => { const parsed = parseGcodeToolpath(` M83 G21 G28 ; FEATURE: Outer wall G1 X10 Y10 Z0.2 G1 X20 Y10 E0.5 `); expect(parsed.segmentCount).toBe(1); }); it('tracks the active filament across tool changes', () => { const parsed = parseGcodeToolpath(` M83 ; FEATURE: Outer wall T0 G1 X10 Y10 Z0.2 F600 G1 X20 Y10 E0.5 T1 G1 X20 Y20 E0.5 `); const tools: number[] = []; for (const layer of parsed.layers) { for (let i = 0; i < layer.paths.length; i += 8) { if (layer.paths[i + 3] !== ToolpathType.travel) tools.push(layer.paths[i + 7]); } } expect(tools).toContain(0); expect(tools).toContain(1); }); it("ignores BambuStudio's sentinel tool numbers", () => { // T65535 / T65279 bracket the slicer's own bookkeeping and are not // filaments; treating them as such would key colours off a nonsense slot. const parsed = parseGcodeToolpath(` M83 ; FEATURE: Outer wall T0 G1 X10 Y10 Z0.2 F600 T65535 G1 X20 Y10 E0.5 `); for (const layer of parsed.layers) { for (let i = 0; i < layer.paths.length; i += 8) { expect(layer.paths[i + 7]).toBeLessThanOrEqual(15); } } }); it('re-keys types to filaments for the filament-coloured view', () => { const parsed = parseGcodeToolpath(` M83 ; FEATURE: Support T1 G1 X10 Y10 Z0.2 F600 G1 X20 Y10 E0.5 `); const recoloured = layersByFilament(parsed.layers); const typeOf = (layers: typeof parsed.layers) => { for (const layer of layers) { for (let i = 0; i < layer.paths.length; i += 8) { if (layer.paths[i + 3] !== ToolpathType.travel) return layer.paths[i + 3]; } } return -1; }; expect(typeOf(parsed.layers)).toBe(ToolpathType.support); // Filament 1 becomes type 2 -- offset by one so slot 0 cannot collide // with the travel index. expect(typeOf(recoloured)).toBe(2); // The original must be untouched: both colourings are held at once. expect(typeOf(parsed.layers)).toBe(ToolpathType.support); }); }); describe('hiding a feature or filament', () => { const GCODE = ` M83 ; LINE_WIDTH: 0.42 ; FEATURE: Outer wall T0 G1 X10 Y10 Z0.2 F600 G1 X20 Y10 E0.5 ; FEATURE: Support T1 G1 X30 Y10 E0.5 G1 X40 Y10 E0.5 `; const typesOf = (layers: ReturnType['layers']) => { const out: number[] = []; for (const layer of layers) { for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]); } return out; }; it('removes the hidden feature and keeps the rest', () => { const parsed = parseGcodeToolpath(GCODE); const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.support])); expect(typesOf(filtered)).not.toContain(ToolpathType.support); expect(typesOf(filtered)).toContain(ToolpathType.wall); }); it('keeps emptied layers so the range slider does not renumber', () => { // Dropping a layer that the filter emptied would shift every layer above // it, and the slider would then point at the wrong height. const parsed = parseGcodeToolpath(GCODE); const everything = new Set(typesOf(parsed.layers)); const filtered = filterLayersByType(parsed.layers, everything); expect(filtered.length).toBe(parsed.layers.length); expect(typesOf(filtered)).toEqual([]); }); it('keeps each kept record paired with its own width', () => { // The widths array is indexed in step with the records; dropping one // without dropping its width would smear widths across the rest. const parsed = parseGcodeToolpath(GCODE); const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.travel])); for (const layer of filtered) { expect(layer.widths.length).toBe(layer.paths.length / 8); } }); it('hides by filament once the types are re-keyed', () => { const parsed = parseGcodeToolpath(GCODE); const byFilament = layersByFilament(parsed.layers); // Filament 1 is keyed as type 2. const filtered = filterLayersByType(byFilament, new Set([2])); expect(typesOf(filtered)).not.toContain(2); expect(typesOf(filtered)).toContain(1); }); it('returns the input untouched when nothing is hidden', () => { const parsed = parseGcodeToolpath(GCODE); expect(filterLayersByType(parsed.layers, new Set())).toBe(parsed.layers); }); });