mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
Sliced files previewed through a vendored copy of PrettyGCode in an iframe. It drew each move as a screen-space line -- a line has no thickness in the scene, so it cannot occlude the layer behind it, which is why prints came out stringy and shimmered where layers crossed. Being a separate app in a frame, it could be neither themed nor translated, and carried its own machinery for detecting a proxy refusing the embed. Now built on libvgcode, the renderer OrcaSlicer draws its own preview with, vendored from three-slicer (AGPL, same as us). It takes the THREE namespace as an argument and imports nothing, so it runs on our 0.181 rather than the 0.160 its package pins. The parser is ours; upstream renders its own kernel's output and ships no G-code parser at all. Two things it has to get right, both found by checking a real plate rather than assuming: - BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading only the latter showed a 52-layer print as 23,165 layers in one colour, because with no layer marker recognised every travel Z-hop split a layer and every segment took the fallback feature. - It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a single plate. Ignoring them punched holes through curved walls and tree supports. Arcs with no X/Y are the helical travel lift and lay down nothing, so they interpolate as travels. Four colour modes: filament (default, from the AMS slots the file was sliced with), feature, layer height, line width. Speed, fan and temperature are deliberately absent -- upstream derives those from settings rather than the toolpath, and guesses dressed as measurements are worse than an honest omission. The parser now carries the data to do them properly later. Legend entries are switches. Hiding removes the records before the mesh is built rather than recolouring them: the shader packs colour into a single float with no alpha, so there is no transparent to set, and removal is the useful behaviour anyway -- a hidden support stops occluding what it covered. The scene is built once and only the toolpath rebuilds. Doing otherwise constructed a new WebGLRenderer on every render, because the buildVolume default is an object literal and so a fresh identity each time; browsers cap live WebGL contexts and drop the oldest, which blanked the canvas after a few interactions. utils/framing.ts goes with the iframe, along with six now-orphaned strings in all 13 locales. src/lib/vendor is excluded from eslint -- acting on findings in vendored code makes it impossible to re-copy on the next upstream release.
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|