gcodeToolpath.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. /**
  2. * Parses a G-code file into the per-layer form the vendored libvgcode renderer
  3. * consumes (`src/lib/vendor/toolpathRenderer.js`).
  4. *
  5. * This is the piece that does not exist upstream. `three-slicer` renders its own
  6. * slicing kernel's output and ships no G-code parser at all, so a preview of a
  7. * *file* -- which is all Bambuddy ever has -- needs the toolpath reconstructed
  8. * from the text.
  9. *
  10. * The renderer's input is one entry per layer:
  11. *
  12. * { z, paths: Float32Array (stride 8), widths: number[] }
  13. *
  14. * where each stride-8 record is `x0, y0, z0, type, x1, y1, z1, _` and `type` is
  15. * 0 for a travel move or a feature index otherwise. Layer height is derived by
  16. * the renderer from the gaps between consecutive `z` values, so layers must
  17. * arrive in print order.
  18. *
  19. * Deliberately hand-rolled rather than reusing `gcode-preview`'s parser: that
  20. * one models moves for a line renderer and keeps `;TYPE:` only as an opaque
  21. * comment string, so the feature classification below -- the thing that makes a
  22. * preview readable -- would have to be written here anyway.
  23. */
  24. /**
  25. * Feature indices the renderer's palette is keyed on. Values are fixed by
  26. * `TYPE_COLOR` in the vendored module, which took them from libvgcode; changing
  27. * one silently recolours the preview.
  28. */
  29. export const ToolpathType = {
  30. travel: 0,
  31. wall: 1,
  32. sparseInfill: 2,
  33. solidInfill: 3,
  34. skirt: 4,
  35. support: 5,
  36. raft: 6,
  37. gapFill: 7,
  38. thinWall: 8,
  39. bridge: 9,
  40. ironing: 10,
  41. primeTower: 11,
  42. } as const;
  43. /**
  44. * `;TYPE:` values as OrcaSlicer and BambuStudio emit them, lowercased.
  45. *
  46. * Both spell several of these differently across versions ("Overhang wall" vs
  47. * "Overhang perimeter"), and PrusaSlicer-lineage names turn up in third-party
  48. * files, so the table is deliberately generous. Anything unrecognised falls
  49. * back to `wall`, which is visually neutral -- better a mis-coloured segment
  50. * than a missing one, since an unknown type must never drop geometry.
  51. */
  52. const FEATURE_BY_COMMENT: Record<string, number> = {
  53. 'outer wall': ToolpathType.wall,
  54. 'inner wall': ToolpathType.wall,
  55. perimeter: ToolpathType.wall,
  56. 'external perimeter': ToolpathType.wall,
  57. 'overhang wall': ToolpathType.bridge,
  58. 'overhang perimeter': ToolpathType.bridge,
  59. 'sparse infill': ToolpathType.sparseInfill,
  60. 'internal infill': ToolpathType.sparseInfill,
  61. 'solid infill': ToolpathType.solidInfill,
  62. 'internal solid infill': ToolpathType.solidInfill,
  63. 'top surface': ToolpathType.solidInfill,
  64. 'top solid infill': ToolpathType.solidInfill,
  65. 'bottom surface': ToolpathType.solidInfill,
  66. skirt: ToolpathType.skirt,
  67. 'skirt/brim': ToolpathType.skirt,
  68. brim: ToolpathType.skirt,
  69. support: ToolpathType.support,
  70. 'support material': ToolpathType.support,
  71. 'support interface': ToolpathType.support,
  72. 'support material interface': ToolpathType.support,
  73. 'support transition': ToolpathType.support,
  74. raft: ToolpathType.raft,
  75. 'gap fill': ToolpathType.gapFill,
  76. 'gap infill': ToolpathType.gapFill,
  77. 'thin wall': ToolpathType.thinWall,
  78. // Bambu-only names.
  79. 'floating vertical shell': ToolpathType.solidInfill,
  80. 'internal bridge': ToolpathType.bridge,
  81. 'bottom shell': ToolpathType.solidInfill,
  82. bridge: ToolpathType.bridge,
  83. 'bridge infill': ToolpathType.bridge,
  84. 'internal bridge infill': ToolpathType.bridge,
  85. ironing: ToolpathType.ironing,
  86. 'prime tower': ToolpathType.primeTower,
  87. 'wipe tower': ToolpathType.primeTower,
  88. custom: ToolpathType.wall,
  89. };
  90. /** One layer in the shape `buildSegmentData` expects. */
  91. export interface ToolpathLayer {
  92. z: number;
  93. paths: Float32Array;
  94. widths: number[];
  95. }
  96. export interface ParsedToolpath {
  97. layers: ToolpathLayer[];
  98. /** Extruding segments, excluding travels. */
  99. segmentCount: number;
  100. travelCount: number;
  101. /** Nozzle/line width seen in the file, for the renderer's fallback. */
  102. defaultWidth: number;
  103. bounds: { min: [number, number, number]; max: [number, number, number] } | null;
  104. }
  105. const RECORD_STRIDE = 8;
  106. const TAU = Math.PI * 2;
  107. /** Chord flatness for arc interpolation, in mm. Below an extrusion width. */
  108. const ARC_TOLERANCE_MM = 0.02;
  109. /** Ceiling on chords per arc, so a huge radius cannot blow up the buffer. */
  110. const ARC_MAX_CHORDS = 256;
  111. /** Tool numbers above this are slicer sentinels, not filaments. */
  112. const MAX_TOOL = 15;
  113. /** Growable stride-8 record buffer; typed arrays cannot be pushed to. */
  114. class PathBuffer {
  115. private data = new Float32Array(1024 * RECORD_STRIDE);
  116. private count = 0;
  117. readonly widths: number[] = [];
  118. push(
  119. x0: number, y0: number, z0: number,
  120. type: number,
  121. x1: number, y1: number, z1: number,
  122. width: number,
  123. tool: number,
  124. ): void {
  125. if ((this.count + 1) * RECORD_STRIDE > this.data.length) {
  126. const grown = new Float32Array(this.data.length * 2);
  127. grown.set(this.data);
  128. this.data = grown;
  129. }
  130. const o = this.count * RECORD_STRIDE;
  131. this.data[o] = x0;
  132. this.data[o + 1] = y0;
  133. this.data[o + 2] = z0;
  134. this.data[o + 3] = type;
  135. this.data[o + 4] = x1;
  136. this.data[o + 5] = y1;
  137. this.data[o + 6] = z1;
  138. // Slot 7 is unread by the renderer, so the active filament rides along in
  139. // it. That is what lets the viewer offer a filament-coloured view without
  140. // parsing the file twice: it swaps slot 3 for slot 7 and rebuilds.
  141. this.data[o + 7] = tool;
  142. this.count += 1;
  143. this.widths.push(width);
  144. }
  145. get length(): number {
  146. return this.count;
  147. }
  148. /** Trimmed copy — the renderer walks the whole array, so slack would render. */
  149. toFloat32Array(): Float32Array {
  150. return this.data.slice(0, this.count * RECORD_STRIDE);
  151. }
  152. }
  153. /** Most frequently seen key, or undefined when the tally is empty. */
  154. function modeOf(tally: Map<number, number>): number | undefined {
  155. let best: number | undefined;
  156. let bestCount = 0;
  157. for (const [value, count] of tally) {
  158. if (count > bestCount) {
  159. best = value;
  160. bestCount = count;
  161. }
  162. }
  163. return best;
  164. }
  165. /** Reads a named axis out of a `G0`/`G1` line without allocating per token. */
  166. function readAxis(line: string, axis: string): number | undefined {
  167. const at = line.indexOf(axis);
  168. if (at < 0) return undefined;
  169. // Guard against matching inside a comment or a word ("; X marks").
  170. const value = Number.parseFloat(line.slice(at + 1));
  171. return Number.isFinite(value) ? value : undefined;
  172. }
  173. /**
  174. * Parse G-code into per-layer toolpath records.
  175. *
  176. * Relative extrusion (`M83`) and absolute (`M82`) are both handled, because
  177. * Bambu writes relative and plenty of third-party files do not. Anything the
  178. * parser cannot make sense of is skipped rather than guessed at.
  179. */
  180. export function parseGcodeToolpath(gcode: string): ParsedToolpath {
  181. const layers: ToolpathLayer[] = [];
  182. let x = 0;
  183. let y = 0;
  184. let z = 0;
  185. let e = 0;
  186. let relativeExtrusion = false;
  187. let feature: number = ToolpathType.wall;
  188. let width = 0;
  189. // Tally of observed widths. The *typical* one is wanted, not the largest: a
  190. // file's widths range from a 0.09 gap fill to a 1.0 purge line, and taking
  191. // the max made the fallback wildly too fat.
  192. const widthTally = new Map<number, number>();
  193. let segmentCount = 0;
  194. let travelCount = 0;
  195. let current = new PathBuffer();
  196. let currentZ = 0;
  197. // A file with explicit layer markers is trusted; without them, layers are
  198. // inferred from the Z at which material is *laid down*.
  199. let sawLayerMarker = false;
  200. let pendingZ: number | null = null;
  201. // Travels share the layer buffer, so "the buffer is empty" is not the same
  202. // question as "this layer has laid anything down yet" -- and it is the first
  203. // *extrusion* that fixes a layer's height.
  204. let layerHasExtrusion = false;
  205. // Active filament. BambuStudio also emits sentinel tool numbers
  206. // (T65535 / T65279) around its own bookkeeping; those are not filaments.
  207. let tool = 0;
  208. // Suppresses a phantom segment from the origin: the machine's position is
  209. // unknown until the first move sets it, and drawing from (0,0,0) put a stray
  210. // line across the bed.
  211. let hasPosition = false;
  212. let minX = Infinity, minY = Infinity, minZ = Infinity;
  213. let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  214. const flushLayer = () => {
  215. if (current.length === 0) return;
  216. layers.push({ z: currentZ, paths: current.toFloat32Array(), widths: current.widths });
  217. current = new PathBuffer();
  218. layerHasExtrusion = false;
  219. if (pendingZ !== null) {
  220. currentZ = pendingZ;
  221. pendingZ = null;
  222. }
  223. };
  224. /**
  225. * Record one straight run from the current position, updating bounds. Shared
  226. * by linear moves and by each chord an arc is flattened into.
  227. */
  228. const emit = (nx: number, ny: number, nz: number, extruding: boolean) => {
  229. if (extruding) {
  230. if (!sawLayerMarker && layerHasExtrusion && nz !== currentZ) flushLayer();
  231. if (!layerHasExtrusion) {
  232. currentZ = nz;
  233. pendingZ = null;
  234. }
  235. layerHasExtrusion = true;
  236. if (!hasPosition) {
  237. hasPosition = true;
  238. x = nx; y = ny; z = nz;
  239. return;
  240. }
  241. current.push(x, y, z, feature, nx, ny, nz, width, tool);
  242. segmentCount += 1;
  243. if (nx < minX) minX = nx;
  244. if (ny < minY) minY = ny;
  245. if (nz < minZ) minZ = nz;
  246. if (nx > maxX) maxX = nx;
  247. if (ny > maxY) maxY = ny;
  248. if (nz > maxZ) maxZ = nz;
  249. } else if (hasPosition) {
  250. current.push(x, y, z, ToolpathType.travel, nx, ny, nz, 0, tool);
  251. travelCount += 1;
  252. }
  253. x = nx; y = ny; z = nz;
  254. hasPosition = true;
  255. };
  256. for (const rawLine of gcode.split('\n')) {
  257. const line = rawLine.trim();
  258. if (line.length === 0) continue;
  259. if (line.charCodeAt(0) === 59 /* ; */) {
  260. // Slicer annotations, in either dialect. BambuStudio writes
  261. // "; FEATURE: Outer wall" and "; CHANGE_LAYER"; OrcaSlicer and the
  262. // PrusaSlicer lineage write ";TYPE:Outer wall" and ";LAYER_CHANGE".
  263. // Reading only one of them is why an earlier version of this parser
  264. // rendered a Bambu file as a single undifferentiated colour.
  265. const body = line.slice(1).trimStart();
  266. const colon = body.indexOf(':');
  267. const key = (colon >= 0 ? body.slice(0, colon) : body).trim().toUpperCase();
  268. const value = colon >= 0 ? body.slice(colon + 1).trim() : '';
  269. if (key === 'FEATURE' || key === 'TYPE') {
  270. feature = FEATURE_BY_COMMENT[value.toLowerCase()] ?? ToolpathType.wall;
  271. } else if (key === 'LINE_WIDTH' || key === 'WIDTH') {
  272. const parsed = Number.parseFloat(value);
  273. if (Number.isFinite(parsed) && parsed > 0) {
  274. width = parsed;
  275. widthTally.set(parsed, (widthTally.get(parsed) ?? 0) + 1);
  276. }
  277. } else if (key === 'CHANGE_LAYER' || key === 'LAYER_CHANGE') {
  278. // An explicit marker is authoritative: it is the only thing that
  279. // distinguishes a real layer change from a travel Z-hop.
  280. sawLayerMarker = true;
  281. flushLayer();
  282. } else if (key === 'Z_HEIGHT' || key === 'Z') {
  283. const parsed = Number.parseFloat(value);
  284. if (Number.isFinite(parsed)) pendingZ = parsed;
  285. }
  286. continue;
  287. }
  288. if (line.startsWith('M83')) {
  289. relativeExtrusion = true;
  290. continue;
  291. }
  292. if (line.startsWith('M82')) {
  293. relativeExtrusion = false;
  294. continue;
  295. }
  296. if (line.startsWith('G92')) {
  297. const resetE = readAxis(line, 'E');
  298. if (resetE !== undefined) e = resetE;
  299. continue;
  300. }
  301. if (line.charCodeAt(0) === 84 /* T */) {
  302. // Filament change. Values above the sensible tool range are BambuStudio
  303. // sentinels around its own bookkeeping (T65535 / T65279), not filaments.
  304. const picked = Number.parseInt(line.slice(1), 10);
  305. if (Number.isFinite(picked) && picked >= 0 && picked <= MAX_TOOL) tool = picked;
  306. continue;
  307. }
  308. const isArc = line.startsWith('G2 ') || line.startsWith('G3 ') || line.startsWith('G2') || line.startsWith('G3');
  309. const isLinear = line.startsWith('G1') || line.startsWith('G0');
  310. if (!isLinear && !isArc) continue;
  311. // G20/G21/G28 etc. share the G-prefix; only the four move codes above are
  312. // handled, and `startsWith('G2')` would otherwise swallow G20/G28.
  313. if (isArc && !/^G[23](\s|$)/.test(line)) continue;
  314. if (isLinear && !/^G[01](\s|$)/.test(line)) continue;
  315. const nx = readAxis(line, 'X') ?? x;
  316. const ny = readAxis(line, 'Y') ?? y;
  317. const nz = readAxis(line, 'Z') ?? z;
  318. const rawE = readAxis(line, 'E');
  319. let extruded = 0;
  320. if (rawE !== undefined) {
  321. extruded = relativeExtrusion ? rawE : rawE - e;
  322. e = rawE;
  323. }
  324. const extruding = extruded > 0;
  325. if (isArc) {
  326. // Arc move in the XY plane (every file seen uses G17, and I/J rather
  327. // than R). Ignoring these dropped 706 extruding moves out of ~8500 in a
  328. // single plate -- concentrated on curved walls and tree supports, which
  329. // is precisely where the preview came out full of holes.
  330. const i = readAxis(line, 'I') ?? 0;
  331. const j = readAxis(line, 'J') ?? 0;
  332. const cx = x + i;
  333. const cy = y + j;
  334. const radius = Math.hypot(i, j);
  335. if (radius > 0) {
  336. const startAngle = Math.atan2(y - cy, x - cx);
  337. const endAngle = Math.atan2(ny - cy, nx - cx);
  338. const clockwise = line.charCodeAt(1) === 50; /* G2 */
  339. let sweep = endAngle - startAngle;
  340. if (clockwise) {
  341. while (sweep >= 0) sweep -= TAU;
  342. while (sweep < -TAU) sweep += TAU;
  343. } else {
  344. while (sweep <= 0) sweep += TAU;
  345. while (sweep > TAU) sweep -= TAU;
  346. }
  347. // A move with no X/Y is a full turn -- BambuStudio's helical travel
  348. // lift -- and `P` says how many.
  349. if (nx === x && ny === y) {
  350. const turns = Math.max(1, Math.round(readAxis(line, 'P') ?? 1));
  351. sweep = (clockwise ? -TAU : TAU) * turns;
  352. }
  353. // Chord count from a flatness tolerance rather than a fixed step, so a
  354. // 40mm arc is not drawn with the same four chords as a 1mm one.
  355. const maxStep = 2 * Math.acos(Math.max(-1, Math.min(1, 1 - ARC_TOLERANCE_MM / radius)));
  356. const steps = Math.max(1, Math.min(ARC_MAX_CHORDS, Math.ceil(Math.abs(sweep) / Math.max(maxStep, 1e-3))));
  357. for (let step = 1; step <= steps; step += 1) {
  358. const fraction = step / steps;
  359. const angle = startAngle + sweep * fraction;
  360. emit(
  361. cx + radius * Math.cos(angle),
  362. cy + radius * Math.sin(angle),
  363. z + (nz - z) * fraction,
  364. extruding,
  365. );
  366. }
  367. continue;
  368. }
  369. // Degenerate arc (no radius): fall through and treat it as a straight
  370. // move rather than dropping the geometry.
  371. }
  372. if (nx !== x || ny !== y || nz !== z) emit(nx, ny, nz, extruding);
  373. }
  374. flushLayer();
  375. return {
  376. layers,
  377. segmentCount,
  378. travelCount,
  379. defaultWidth: modeOf(widthTally) ?? 0.42,
  380. bounds: segmentCount > 0 ? { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] } : null,
  381. };
  382. }
  383. /**
  384. * Re-key a parsed toolpath so each record's *type* is its filament rather than
  385. * its feature, for a filament-coloured view.
  386. *
  387. * Cheaper than parsing twice, and it has to be a copy rather than an in-place
  388. * edit: the renderer merges adjacent vertices only when their type matches, so
  389. * the two colourings genuinely produce different vertex streams and cannot
  390. * share one built mesh.
  391. *
  392. * Travels keep type 0 so they stay travels.
  393. */
  394. export function layersByFilament(layers: ToolpathLayer[]): ToolpathLayer[] {
  395. return layers.map((layer) => {
  396. const paths = layer.paths.slice();
  397. for (let i = 0; i < paths.length; i += RECORD_STRIDE) {
  398. if (paths[i + 3] !== ToolpathType.travel) {
  399. // +1 so filament 0 does not collide with the travel index.
  400. paths[i + 3] = Math.min(paths[i + 7] + 1, MAX_TOOL);
  401. }
  402. }
  403. return { ...layer, paths };
  404. });
  405. }
  406. /**
  407. * Drop records whose type is hidden, so they never reach the renderer.
  408. *
  409. * Hiding has to happen here rather than by recolouring: the shader packs
  410. * colour into a single float with no alpha channel, so there is no
  411. * "transparent" to set. Removing the records is also what makes hiding
  412. * useful -- a hidden support genuinely stops occluding the model behind it.
  413. */
  414. export function filterLayersByType(layers: ToolpathLayer[], hidden: ReadonlySet<number>): ToolpathLayer[] {
  415. if (hidden.size === 0) return layers;
  416. const out: ToolpathLayer[] = [];
  417. for (const layer of layers) {
  418. const kept = new PathBuffer();
  419. for (let i = 0; i < layer.paths.length; i += RECORD_STRIDE) {
  420. const type = layer.paths[i + 3];
  421. if (hidden.has(type)) continue;
  422. kept.push(
  423. layer.paths[i], layer.paths[i + 1], layer.paths[i + 2], type,
  424. layer.paths[i + 4], layer.paths[i + 5], layer.paths[i + 6],
  425. layer.widths[i / RECORD_STRIDE] ?? 0,
  426. layer.paths[i + 7],
  427. );
  428. }
  429. // A layer emptied by the filter is still a layer: dropping it would
  430. // renumber every layer above it and make the range slider lie.
  431. out.push({ ...layer, paths: kept.toFloat32Array(), widths: kept.widths });
  432. }
  433. return out;
  434. }