| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- /**
- * Parses a G-code file into the per-layer form the vendored libvgcode renderer
- * consumes (`src/lib/vendor/toolpathRenderer.js`).
- *
- * This is the piece that does not exist upstream. `three-slicer` renders its own
- * slicing kernel's output and ships no G-code parser at all, so a preview of a
- * *file* -- which is all Bambuddy ever has -- needs the toolpath reconstructed
- * from the text.
- *
- * The renderer's input is one entry per layer:
- *
- * { z, paths: Float32Array (stride 8), widths: number[] }
- *
- * where each stride-8 record is `x0, y0, z0, type, x1, y1, z1, _` and `type` is
- * 0 for a travel move or a feature index otherwise. Layer height is derived by
- * the renderer from the gaps between consecutive `z` values, so layers must
- * arrive in print order.
- *
- * Deliberately hand-rolled rather than reusing `gcode-preview`'s parser: that
- * one models moves for a line renderer and keeps `;TYPE:` only as an opaque
- * comment string, so the feature classification below -- the thing that makes a
- * preview readable -- would have to be written here anyway.
- */
- /**
- * Feature indices the renderer's palette is keyed on. Values are fixed by
- * `TYPE_COLOR` in the vendored module, which took them from libvgcode; changing
- * one silently recolours the preview.
- */
- export const ToolpathType = {
- travel: 0,
- wall: 1,
- sparseInfill: 2,
- solidInfill: 3,
- skirt: 4,
- support: 5,
- raft: 6,
- gapFill: 7,
- thinWall: 8,
- bridge: 9,
- ironing: 10,
- primeTower: 11,
- } as const;
- /**
- * `;TYPE:` values as OrcaSlicer and BambuStudio emit them, lowercased.
- *
- * Both spell several of these differently across versions ("Overhang wall" vs
- * "Overhang perimeter"), and PrusaSlicer-lineage names turn up in third-party
- * files, so the table is deliberately generous. Anything unrecognised falls
- * back to `wall`, which is visually neutral -- better a mis-coloured segment
- * than a missing one, since an unknown type must never drop geometry.
- */
- const FEATURE_BY_COMMENT: Record<string, number> = {
- 'outer wall': ToolpathType.wall,
- 'inner wall': ToolpathType.wall,
- perimeter: ToolpathType.wall,
- 'external perimeter': ToolpathType.wall,
- 'overhang wall': ToolpathType.bridge,
- 'overhang perimeter': ToolpathType.bridge,
- 'sparse infill': ToolpathType.sparseInfill,
- 'internal infill': ToolpathType.sparseInfill,
- 'solid infill': ToolpathType.solidInfill,
- 'internal solid infill': ToolpathType.solidInfill,
- 'top surface': ToolpathType.solidInfill,
- 'top solid infill': ToolpathType.solidInfill,
- 'bottom surface': ToolpathType.solidInfill,
- skirt: ToolpathType.skirt,
- 'skirt/brim': ToolpathType.skirt,
- brim: ToolpathType.skirt,
- support: ToolpathType.support,
- 'support material': ToolpathType.support,
- 'support interface': ToolpathType.support,
- 'support material interface': ToolpathType.support,
- 'support transition': ToolpathType.support,
- raft: ToolpathType.raft,
- 'gap fill': ToolpathType.gapFill,
- 'gap infill': ToolpathType.gapFill,
- 'thin wall': ToolpathType.thinWall,
- // Bambu-only names.
- 'floating vertical shell': ToolpathType.solidInfill,
- 'internal bridge': ToolpathType.bridge,
- 'bottom shell': ToolpathType.solidInfill,
- bridge: ToolpathType.bridge,
- 'bridge infill': ToolpathType.bridge,
- 'internal bridge infill': ToolpathType.bridge,
- ironing: ToolpathType.ironing,
- 'prime tower': ToolpathType.primeTower,
- 'wipe tower': ToolpathType.primeTower,
- custom: ToolpathType.wall,
- };
- /** One layer in the shape `buildSegmentData` expects. */
- export interface ToolpathLayer {
- z: number;
- paths: Float32Array;
- widths: number[];
- }
- export interface ParsedToolpath {
- layers: ToolpathLayer[];
- /** Extruding segments, excluding travels. */
- segmentCount: number;
- travelCount: number;
- /** Nozzle/line width seen in the file, for the renderer's fallback. */
- defaultWidth: number;
- bounds: { min: [number, number, number]; max: [number, number, number] } | null;
- }
- const RECORD_STRIDE = 8;
- const TAU = Math.PI * 2;
- /** Chord flatness for arc interpolation, in mm. Below an extrusion width. */
- const ARC_TOLERANCE_MM = 0.02;
- /** Ceiling on chords per arc, so a huge radius cannot blow up the buffer. */
- const ARC_MAX_CHORDS = 256;
- /** Tool numbers above this are slicer sentinels, not filaments. */
- const MAX_TOOL = 15;
- /** Growable stride-8 record buffer; typed arrays cannot be pushed to. */
- class PathBuffer {
- private data = new Float32Array(1024 * RECORD_STRIDE);
- private count = 0;
- readonly widths: number[] = [];
- push(
- x0: number, y0: number, z0: number,
- type: number,
- x1: number, y1: number, z1: number,
- width: number,
- tool: number,
- ): void {
- if ((this.count + 1) * RECORD_STRIDE > this.data.length) {
- const grown = new Float32Array(this.data.length * 2);
- grown.set(this.data);
- this.data = grown;
- }
- const o = this.count * RECORD_STRIDE;
- this.data[o] = x0;
- this.data[o + 1] = y0;
- this.data[o + 2] = z0;
- this.data[o + 3] = type;
- this.data[o + 4] = x1;
- this.data[o + 5] = y1;
- this.data[o + 6] = z1;
- // Slot 7 is unread by the renderer, so the active filament rides along in
- // it. That is what lets the viewer offer a filament-coloured view without
- // parsing the file twice: it swaps slot 3 for slot 7 and rebuilds.
- this.data[o + 7] = tool;
- this.count += 1;
- this.widths.push(width);
- }
- get length(): number {
- return this.count;
- }
- /** Trimmed copy — the renderer walks the whole array, so slack would render. */
- toFloat32Array(): Float32Array {
- return this.data.slice(0, this.count * RECORD_STRIDE);
- }
- }
- /** Most frequently seen key, or undefined when the tally is empty. */
- function modeOf(tally: Map<number, number>): number | undefined {
- let best: number | undefined;
- let bestCount = 0;
- for (const [value, count] of tally) {
- if (count > bestCount) {
- best = value;
- bestCount = count;
- }
- }
- return best;
- }
- /** Reads a named axis out of a `G0`/`G1` line without allocating per token. */
- function readAxis(line: string, axis: string): number | undefined {
- const at = line.indexOf(axis);
- if (at < 0) return undefined;
- // Guard against matching inside a comment or a word ("; X marks").
- const value = Number.parseFloat(line.slice(at + 1));
- return Number.isFinite(value) ? value : undefined;
- }
- /**
- * Parse G-code into per-layer toolpath records.
- *
- * Relative extrusion (`M83`) and absolute (`M82`) are both handled, because
- * Bambu writes relative and plenty of third-party files do not. Anything the
- * parser cannot make sense of is skipped rather than guessed at.
- */
- export function parseGcodeToolpath(gcode: string): ParsedToolpath {
- const layers: ToolpathLayer[] = [];
- let x = 0;
- let y = 0;
- let z = 0;
- let e = 0;
- let relativeExtrusion = false;
- let feature: number = ToolpathType.wall;
- let width = 0;
- // Tally of observed widths. The *typical* one is wanted, not the largest: a
- // file's widths range from a 0.09 gap fill to a 1.0 purge line, and taking
- // the max made the fallback wildly too fat.
- const widthTally = new Map<number, number>();
- let segmentCount = 0;
- let travelCount = 0;
- let current = new PathBuffer();
- let currentZ = 0;
- // A file with explicit layer markers is trusted; without them, layers are
- // inferred from the Z at which material is *laid down*.
- let sawLayerMarker = false;
- let pendingZ: number | null = null;
- // Travels share the layer buffer, so "the buffer is empty" is not the same
- // question as "this layer has laid anything down yet" -- and it is the first
- // *extrusion* that fixes a layer's height.
- let layerHasExtrusion = false;
- // Active filament. BambuStudio also emits sentinel tool numbers
- // (T65535 / T65279) around its own bookkeeping; those are not filaments.
- let tool = 0;
- // Suppresses a phantom segment from the origin: the machine's position is
- // unknown until the first move sets it, and drawing from (0,0,0) put a stray
- // line across the bed.
- let hasPosition = false;
- let minX = Infinity, minY = Infinity, minZ = Infinity;
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
- const flushLayer = () => {
- if (current.length === 0) return;
- layers.push({ z: currentZ, paths: current.toFloat32Array(), widths: current.widths });
- current = new PathBuffer();
- layerHasExtrusion = false;
- if (pendingZ !== null) {
- currentZ = pendingZ;
- pendingZ = null;
- }
- };
- /**
- * Record one straight run from the current position, updating bounds. Shared
- * by linear moves and by each chord an arc is flattened into.
- */
- const emit = (nx: number, ny: number, nz: number, extruding: boolean) => {
- if (extruding) {
- if (!sawLayerMarker && layerHasExtrusion && nz !== currentZ) flushLayer();
- if (!layerHasExtrusion) {
- currentZ = nz;
- pendingZ = null;
- }
- layerHasExtrusion = true;
- if (!hasPosition) {
- hasPosition = true;
- x = nx; y = ny; z = nz;
- return;
- }
- current.push(x, y, z, feature, nx, ny, nz, width, tool);
- segmentCount += 1;
- if (nx < minX) minX = nx;
- if (ny < minY) minY = ny;
- if (nz < minZ) minZ = nz;
- if (nx > maxX) maxX = nx;
- if (ny > maxY) maxY = ny;
- if (nz > maxZ) maxZ = nz;
- } else if (hasPosition) {
- current.push(x, y, z, ToolpathType.travel, nx, ny, nz, 0, tool);
- travelCount += 1;
- }
- x = nx; y = ny; z = nz;
- hasPosition = true;
- };
- for (const rawLine of gcode.split('\n')) {
- const line = rawLine.trim();
- if (line.length === 0) continue;
- if (line.charCodeAt(0) === 59 /* ; */) {
- // Slicer annotations, in either dialect. BambuStudio writes
- // "; FEATURE: Outer wall" and "; CHANGE_LAYER"; OrcaSlicer and the
- // PrusaSlicer lineage write ";TYPE:Outer wall" and ";LAYER_CHANGE".
- // Reading only one of them is why an earlier version of this parser
- // rendered a Bambu file as a single undifferentiated colour.
- const body = line.slice(1).trimStart();
- const colon = body.indexOf(':');
- const key = (colon >= 0 ? body.slice(0, colon) : body).trim().toUpperCase();
- const value = colon >= 0 ? body.slice(colon + 1).trim() : '';
- if (key === 'FEATURE' || key === 'TYPE') {
- feature = FEATURE_BY_COMMENT[value.toLowerCase()] ?? ToolpathType.wall;
- } else if (key === 'LINE_WIDTH' || key === 'WIDTH') {
- const parsed = Number.parseFloat(value);
- if (Number.isFinite(parsed) && parsed > 0) {
- width = parsed;
- widthTally.set(parsed, (widthTally.get(parsed) ?? 0) + 1);
- }
- } else if (key === 'CHANGE_LAYER' || key === 'LAYER_CHANGE') {
- // An explicit marker is authoritative: it is the only thing that
- // distinguishes a real layer change from a travel Z-hop.
- sawLayerMarker = true;
- flushLayer();
- } else if (key === 'Z_HEIGHT' || key === 'Z') {
- const parsed = Number.parseFloat(value);
- if (Number.isFinite(parsed)) pendingZ = parsed;
- }
- continue;
- }
- if (line.startsWith('M83')) {
- relativeExtrusion = true;
- continue;
- }
- if (line.startsWith('M82')) {
- relativeExtrusion = false;
- continue;
- }
- if (line.startsWith('G92')) {
- const resetE = readAxis(line, 'E');
- if (resetE !== undefined) e = resetE;
- continue;
- }
- if (line.charCodeAt(0) === 84 /* T */) {
- // Filament change. Values above the sensible tool range are BambuStudio
- // sentinels around its own bookkeeping (T65535 / T65279), not filaments.
- const picked = Number.parseInt(line.slice(1), 10);
- if (Number.isFinite(picked) && picked >= 0 && picked <= MAX_TOOL) tool = picked;
- continue;
- }
- const isArc = line.startsWith('G2 ') || line.startsWith('G3 ') || line.startsWith('G2') || line.startsWith('G3');
- const isLinear = line.startsWith('G1') || line.startsWith('G0');
- if (!isLinear && !isArc) continue;
- // G20/G21/G28 etc. share the G-prefix; only the four move codes above are
- // handled, and `startsWith('G2')` would otherwise swallow G20/G28.
- if (isArc && !/^G[23](\s|$)/.test(line)) continue;
- if (isLinear && !/^G[01](\s|$)/.test(line)) continue;
- const nx = readAxis(line, 'X') ?? x;
- const ny = readAxis(line, 'Y') ?? y;
- const nz = readAxis(line, 'Z') ?? z;
- const rawE = readAxis(line, 'E');
- let extruded = 0;
- if (rawE !== undefined) {
- extruded = relativeExtrusion ? rawE : rawE - e;
- e = rawE;
- }
- const extruding = extruded > 0;
- if (isArc) {
- // Arc move in the XY plane (every file seen uses G17, and I/J rather
- // than R). Ignoring these dropped 706 extruding moves out of ~8500 in a
- // single plate -- concentrated on curved walls and tree supports, which
- // is precisely where the preview came out full of holes.
- const i = readAxis(line, 'I') ?? 0;
- const j = readAxis(line, 'J') ?? 0;
- const cx = x + i;
- const cy = y + j;
- const radius = Math.hypot(i, j);
- if (radius > 0) {
- const startAngle = Math.atan2(y - cy, x - cx);
- const endAngle = Math.atan2(ny - cy, nx - cx);
- const clockwise = line.charCodeAt(1) === 50; /* G2 */
- let sweep = endAngle - startAngle;
- if (clockwise) {
- while (sweep >= 0) sweep -= TAU;
- while (sweep < -TAU) sweep += TAU;
- } else {
- while (sweep <= 0) sweep += TAU;
- while (sweep > TAU) sweep -= TAU;
- }
- // A move with no X/Y is a full turn -- BambuStudio's helical travel
- // lift -- and `P` says how many.
- if (nx === x && ny === y) {
- const turns = Math.max(1, Math.round(readAxis(line, 'P') ?? 1));
- sweep = (clockwise ? -TAU : TAU) * turns;
- }
- // Chord count from a flatness tolerance rather than a fixed step, so a
- // 40mm arc is not drawn with the same four chords as a 1mm one.
- const maxStep = 2 * Math.acos(Math.max(-1, Math.min(1, 1 - ARC_TOLERANCE_MM / radius)));
- const steps = Math.max(1, Math.min(ARC_MAX_CHORDS, Math.ceil(Math.abs(sweep) / Math.max(maxStep, 1e-3))));
- for (let step = 1; step <= steps; step += 1) {
- const fraction = step / steps;
- const angle = startAngle + sweep * fraction;
- emit(
- cx + radius * Math.cos(angle),
- cy + radius * Math.sin(angle),
- z + (nz - z) * fraction,
- extruding,
- );
- }
- continue;
- }
- // Degenerate arc (no radius): fall through and treat it as a straight
- // move rather than dropping the geometry.
- }
- if (nx !== x || ny !== y || nz !== z) emit(nx, ny, nz, extruding);
- }
- flushLayer();
- return {
- layers,
- segmentCount,
- travelCount,
- defaultWidth: modeOf(widthTally) ?? 0.42,
- bounds: segmentCount > 0 ? { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] } : null,
- };
- }
- /**
- * Re-key a parsed toolpath so each record's *type* is its filament rather than
- * its feature, for a filament-coloured view.
- *
- * Cheaper than parsing twice, and it has to be a copy rather than an in-place
- * edit: the renderer merges adjacent vertices only when their type matches, so
- * the two colourings genuinely produce different vertex streams and cannot
- * share one built mesh.
- *
- * Travels keep type 0 so they stay travels.
- */
- export function layersByFilament(layers: ToolpathLayer[]): ToolpathLayer[] {
- return layers.map((layer) => {
- const paths = layer.paths.slice();
- for (let i = 0; i < paths.length; i += RECORD_STRIDE) {
- if (paths[i + 3] !== ToolpathType.travel) {
- // +1 so filament 0 does not collide with the travel index.
- paths[i + 3] = Math.min(paths[i + 7] + 1, MAX_TOOL);
- }
- }
- return { ...layer, paths };
- });
- }
- /**
- * Drop records whose type is hidden, so they never reach the renderer.
- *
- * Hiding has to happen here rather than by recolouring: the shader packs
- * colour into a single float with no alpha channel, so there is no
- * "transparent" to set. Removing the records is also what makes hiding
- * useful -- a hidden support genuinely stops occluding the model behind it.
- */
- export function filterLayersByType(layers: ToolpathLayer[], hidden: ReadonlySet<number>): ToolpathLayer[] {
- if (hidden.size === 0) return layers;
- const out: ToolpathLayer[] = [];
- for (const layer of layers) {
- const kept = new PathBuffer();
- for (let i = 0; i < layer.paths.length; i += RECORD_STRIDE) {
- const type = layer.paths[i + 3];
- if (hidden.has(type)) continue;
- kept.push(
- layer.paths[i], layer.paths[i + 1], layer.paths[i + 2], type,
- layer.paths[i + 4], layer.paths[i + 5], layer.paths[i + 6],
- layer.widths[i / RECORD_STRIDE] ?? 0,
- layer.paths[i + 7],
- );
- }
- // A layer emptied by the filter is still a layer: dropping it would
- // renumber every layer above it and make the range slider lie.
- out.push({ ...layer, paths: kept.toFloat32Array(), widths: kept.widths });
- }
- return out;
- }
|