Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/bgcode-container-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@chestnutlabs/gcode-bgcode': minor
'@chestnutlabs/gcode-parser': minor
---

Register `.bgcode` as a **container adapter** so it flows through the existing parser pipeline
(DD-011 phase 4c, #188). A `.bgcode` file now "just works" through `GcodeParseSession` with
`containers: 'auto'` — sniffed by magic, decoded to plain G-code, and parsed to the same IR as the
plain `.gcode` (proven by the golden-equivalence test).

- `gcode-bgcode`: `openBgcodeContainer(bytes)` implements the DD-005 §4.4 `{ id, sniff, open }` shape
(single plate; `openPlate(0)` streams the decoded G-code). `openBgcode(bytes, { metadata: true })`
now also decodes the metadata (INI) and thumbnail blocks, so the adapter surfaces **machine geometry
from `bed_shape`**, whitelisted slicer settings (feeding dialect detection + provenance), and
thumbnails.
- `gcode-parser`: the batteries worker registers the `bgcode` adapter beside `gcode-3mf`.

Verified end-to-end: a real Prusa XL cube `.bgcode` parses through the session to 11,417 segments with
a 360×360 bed and `printer_model` metadata.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions packages/gcode-bgcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,25 @@ both attributions preserved in their source files. This package is MIT.
| Encoding: None | ✅ phase 1 |
| Encoding: MeatPack (both variants) | ✅ phase 2 |
| Compression: heatshrink 11/12 | ✅ phase 3 |
| Container-adapter + worker registration + metadata/thumbnails + golden-equivalence | ⏳ phase 4 |
| DEFLATE zlib-flavor fix + golden-equivalence killer test (real Prusa files) | ✅ phase 4 |
| Container adapter + batteries-worker registration + metadata/thumbnail surfacing | ✅ phase 4c |
| Adversarial corpus + §7.3 security review + benchmark | ⏳ phase 5 |

## Usage

```ts
import { openBgcode, sniffBgcode } from '@chestnutlabs/gcode-bgcode';

if (sniffBgcode(firstBytes, name)) {
const { gcode, blocks, checksum } = await openBgcode(bytes);
const { gcode, blocks, checksum } = await openBgcode(bytes, { metadata: true });
// `gcode` is plain G-code (Uint8Array) → feed the existing parser.
}
```

It also registers as a **container adapter** in the batteries parser worker, so a `.bgcode` file "just
works" through `GcodeParseSession` (`containers: 'auto'`) — decoded to G-code, with the machine geometry
(`bed_shape`) and whitelisted slicer settings surfaced as container metadata. Thumbnails are decoded and
available on `openBgcodeContainer(bytes).thumbnails`.

Every failure — bad magic/version, CRC mismatch, truncation, unknown/unsupported codec, or a
decompression bomb — is a structured, bounded `ContainerError` (never a crash or an unbounded read).
56 changes: 56 additions & 0 deletions packages/gcode-bgcode/src/__tests__/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Metadata/thumbnail decoding + the container adapter (DD-011 phase 4c, #188), against the committed
* real Prusa cube fixture.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { openBgcode, openBgcodeContainer, sniffBgcode } from '../index.js';

const cube = new Uint8Array(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../../../test-data/fixtures/bgcode/prim-cube.bgcode'))
);

describe('openBgcode metadata + thumbnails (#188 phase 4c)', () => {
it('surfaces INI settings and thumbnails only when metadata:true', async () => {
const bare = await openBgcode(cube);
expect(Object.keys(bare.settings)).toHaveLength(0);
expect(bare.thumbnails).toHaveLength(0);

const full = await openBgcode(cube, { metadata: true });
expect(full.settings['bed_shape']).toBe('0x0,360x0,360x360,0x360');
expect(full.settings['printer_model']).toBe('XL2IS');
expect(full.settings['Producer']).toContain('PrusaSlicer');
// The cube carries 4 thumbnails (QOI + PNG) with non-empty image bytes.
expect(full.thumbnails.length).toBe(4);
expect(full.thumbnails.every((t) => t.bytes.length > 0 && t.width > 0)).toBe(true);
expect(full.thumbnails.map((t) => t.format)).toContain('png');
});
});

describe('openBgcodeContainer — DD-005 §4.4 adapter shape (#188 phase 4c)', () => {
it('sniffs, exposes one plate, and streams the decoded G-code with machine metadata', async () => {
expect(sniffBgcode(cube.subarray(0, 8))).toBe(true);

const opened = await openBgcodeContainer(cube);
expect(opened.id).toBe('bgcode');
expect(opened.plates).toHaveLength(1);
expect(opened.plates[0].index).toBe(0);
expect(opened.metadata.machine?.bed).toEqual({ kind: 'rect', min: { x: 0, y: 0 }, max: { x: 360, y: 360 } });
expect(opened.metadata.machine?.heightMm).toBe(360);
expect(opened.metadata.machine?.printerName).toBe('XL2IS');
expect(opened.metadata.raw['printer_model']).toBe('XL2IS');
expect(opened.thumbnails.length).toBe(4);

// openPlate(0) yields the full decoded G-code as a one-shot stream.
const reader = opened.openPlate(0).getReader();
const first = await reader.read();
expect(first.done).toBe(false);
expect(first.value!.length).toBeGreaterThan(100_000);
expect(new TextDecoder().decode(first.value!.subarray(0, 40))).toContain('M73');
expect((await reader.read()).done).toBe(true);

expect(() => opened.openPlate(1)).toThrow(/single plate/);
});
});
112 changes: 112 additions & 0 deletions packages/gcode-bgcode/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* `.bgcode` container adapter (DD-011 phase 4c, #188) — plugs `.bgcode` into the existing parser
* pipeline via the DD-005 §4.4 container-adapter contract (`{ id, sniff, open }`), beside `.gcode.3mf`.
* The worker sniffs the input, calls `open`, and parses the single decoded plate; the metadata
* (machine geometry + whitelisted settings) rides beside the IR.
*
* `.bgcode` is single-payload — one plate, no `openPlate` index beyond 0.
*/
import type { MachineGeometry, Point2 } from '@chestnutlabs/toolpath-core';
import { openBgcode, sniffBgcode, type BgcodeThumbnail } from './bgcode.js';

/** Minimal readable-stream mirror the parser worker consumes (same shape as gcode-containers). */
export interface BgcodeStreamLike {
getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; releaseLock?(): void };
}

export interface OpenedBgcode {
id: 'bgcode';
plates: { index: number; name: string; estimatedBytes: number }[];
metadata: {
machine?: MachineGeometry;
raw: Record<string, string>;
warnings: { code: string; message: string }[];
};
/** Thumbnails from the container (bgcode carries them as binary blocks, not G-code comments). */
thumbnails: BgcodeThumbnail[];
openPlate(index: number): BgcodeStreamLike;
}

/** Settings keys copied into `metadata.raw` — a whitelist (feeds dialect detection + provenance), never the whole 390-key config. */
const RAW_WHITELIST = [
'Producer',
'printer_model',
'printer_settings_id',
'print_settings_id',
'bed_shape',
'max_print_height',
'printable_height',
'nozzle_diameter',
'filament_type',
'filament_colour'
];

/** Parse a PrusaSlicer `bed_shape` ("0x0,360x0,360x360,0x360") into points. */
function parseBedShape(value: string): Point2[] | null {
const parts = value.split(',');
if (parts.length < 3 || parts.length > 64) return null;
const points: Point2[] = [];
for (const p of parts) {
const m = /^\s*(-?\d+(?:\.\d+)?)x(-?\d+(?:\.\d+)?)\s*$/.exec(p);
if (m === null) return null;
points.push({ x: parseFloat(m[1]), y: parseFloat(m[2]) });
}
return points;
}

/** Build a MachineGeometry from the decoded `.bgcode` settings (bed_shape + max_print_height + model). */
function machineFrom(settings: Record<string, string>): MachineGeometry | undefined {
const points = settings.bed_shape ? parseBedShape(settings.bed_shape) : null;
if (points === null) return undefined;
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const min = { x: Math.min(...xs), y: Math.min(...ys) };
const max = { x: Math.max(...xs), y: Math.max(...ys) };
const rect =
points.length === 4 && points.every((p) => (p.x === min.x || p.x === max.x) && (p.y === min.y || p.y === max.y));
const h = parseFloat(settings.max_print_height ?? settings.printable_height ?? '');
return {
bed: rect ? { kind: 'rect', min, max } : { kind: 'polygon', points },
origin: { x: 0, y: 0 },
heightMm: Number.isFinite(h) ? h : undefined,
printerName: settings.printer_model,
confidence: 'inferred', // read from the slicer's embedded settings, not machine config
source: { adapterId: 'bgcode', evidence: 'bgcode metadata (bed_shape)' }
};
}

/** A one-shot stream over an in-memory buffer. */
function streamOf(bytes: Uint8Array): BgcodeStreamLike {
return {
getReader() {
let done = false;
return {
read: () => Promise.resolve(done ? { done: true } : ((done = true), { done: false, value: bytes })),
releaseLock() {}
};
}
};
}

/**
* Open a `.bgcode` buffer as a container: decode it to plain G-code (the single plate) and surface its
* metadata + thumbnails. Errors are the structured `ContainerError`s from {@link openBgcode}.
*/
export async function openBgcodeContainer(bytes: Uint8Array): Promise<OpenedBgcode> {
const { gcode, settings, thumbnails } = await openBgcode(bytes, { metadata: true });
const raw: Record<string, string> = {};
for (const key of RAW_WHITELIST) if (settings[key] !== undefined) raw[key] = settings[key];
return {
id: 'bgcode',
plates: [{ index: 0, name: 'bgcode', estimatedBytes: gcode.length }],
metadata: { machine: machineFrom(settings), raw, warnings: [] },
thumbnails,
openPlate(index: number): BgcodeStreamLike {
if (index !== 0) throw new Error(`.bgcode has a single plate; requested ${index}`);
return streamOf(gcode);
}
};
}

/** Re-export the sniff so a worker entry can register `{ id, sniff, open }` in one import. */
export { sniffBgcode };
59 changes: 55 additions & 4 deletions packages/gcode-bgcode/src/bgcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,50 @@ export interface BgcodeBlockInfo {
compressedSize: number;
}

/** A thumbnail decoded from a `.bgcode` Thumbnail block (#188 phase 4c). */
export interface BgcodeThumbnail {
format: 'png' | 'jpg' | 'qoi' | 'unknown';
width: number;
height: number;
bytes: Uint8Array;
}

export interface BgcodeDecodeResult {
/** The decoded plain G-code (all GCode blocks concatenated in file order). */
gcode: Uint8Array;
/** Every block walked, in file order (for diagnostics / phase-4 metadata). */
blocks: BgcodeBlockInfo[];
/** Whether the file declared per-block CRC32. */
checksum: 'none' | 'crc32';
/**
* Merged INI key/values from the File/Printer/Print/Slicer metadata blocks (#188 phase 4c) — only
* populated when `metadata: true`. Later blocks win on key conflicts. Empty otherwise.
*/
settings: Record<string, string>;
/** Thumbnails from Thumbnail blocks (#188 phase 4c) — only when `metadata: true`. */
thumbnails: BgcodeThumbnail[];
}

export interface BgcodeDecodeOptions {
/** Hard cap on total decoded G-code bytes — decompression-bomb defense (default 512 MiB). */
maxOutputBytes?: number;
/** Also decode the metadata (INI) + thumbnail blocks into `settings`/`thumbnails` (default false). */
metadata?: boolean;
}

const THUMB_FORMAT: Record<number, BgcodeThumbnail['format']> = { 0: 'png', 1: 'jpg', 2: 'qoi' };

/** Parse a PrusaSlicer-style INI block (`key = value` lines) into a record. */
function parseIni(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split('\n')) {
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (key.length === 0 || key.startsWith(';') || key.startsWith('#') || key.startsWith('[')) continue;
out[key] = line.slice(eq + 1).trim();
}
return out;
}

const DEFAULT_MAX_OUTPUT = 512 * 1024 * 1024;
Expand Down Expand Up @@ -198,8 +230,11 @@ export async function openBgcode(bytes: Uint8Array, opts: BgcodeDecodeOptions =
if (checksumType > 1) throw new ContainerError('E_BGCODE_CHECKSUM', `unknown checksum type ${checksumType}`);
const hasCrc = checksumType === 1;

const wantMeta = opts.metadata === true;
const blocks: BgcodeBlockInfo[] = [];
const gcodeParts: Uint8Array[] = [];
const settings: Record<string, string> = {};
const thumbnails: BgcodeThumbnail[] = [];
let totalOut = 0;
let off = 10;

Expand All @@ -219,9 +254,13 @@ export async function openBgcode(bytes: Uint8Array, opts: BgcodeDecodeOptions =
}

// Block parameters: 2 bytes (encoding u16) for metadata/GCode; 6 bytes (format+w+h) for thumbnails.
const paramLen = type === BgcodeBlockType.Thumbnail ? 6 : 2;
const isThumb = type === BgcodeBlockType.Thumbnail;
const paramLen = isThumb ? 6 : 2;
if (off + paramLen > len) throw new ContainerError('E_BGCODE_TRUNCATED', 'truncated block parameters');
const encoding = type === BgcodeBlockType.Thumbnail ? 0 : readU16(bytes, off);
const encoding = isThumb ? 0 : readU16(bytes, off);
const thumbFormat = isThumb ? readU16(bytes, off) : 0;
const thumbWidth = isThumb ? readU16(bytes, off + 2) : 0;
const thumbHeight = isThumb ? readU16(bytes, off + 4) : 0;
off += paramLen;

const dataLen = compression === BgcodeCompression.None ? uncompressedSize : compressedSize;
Expand All @@ -248,8 +287,20 @@ export async function openBgcode(bytes: Uint8Array, opts: BgcodeDecodeOptions =
if (totalOut > limit)
throw new ContainerError('E_BGCODE_BOMB', `decoded G-code exceeds the output limit (${totalOut})`);
gcodeParts.push(decoded);
} else if (wantMeta && isThumb) {
// Thumbnails are stored as image bytes (PrusaSlicer uses None compression); decompress if flagged.
const raw = await decompress(compression, data, uncompressedSize, limit);
thumbnails.push({
format: THUMB_FORMAT[thumbFormat] ?? 'unknown',
width: thumbWidth,
height: thumbHeight,
bytes: raw.slice()
});
} else if (wantMeta && (type === 0 || type === 2 || type === 3 || type === 4)) {
// File/Printer/Print/Slicer metadata: an INI block (encoding 0), maybe DEFLATE-compressed.
const ini = await decompress(compression, data, uncompressedSize, limit);
Object.assign(settings, parseIni(new TextDecoder().decode(ini)));
}
// Metadata / thumbnail blocks are walked past here; surfaced via the sink in phase 4.
}

const gcode = new Uint8Array(totalOut);
Expand All @@ -258,5 +309,5 @@ export async function openBgcode(bytes: Uint8Array, opts: BgcodeDecodeOptions =
gcode.set(part, o);
o += part.length;
}
return { gcode, blocks, checksum: hasCrc ? 'crc32' : 'none' };
return { gcode, blocks, checksum: hasCrc ? 'crc32' : 'none', settings, thumbnails };
}
11 changes: 7 additions & 4 deletions packages/gcode-bgcode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* @chestnutlabs/gcode-bgcode — Binary G-code (`.bgcode`) decode adapter (DD-011, epic #188).

Check warning on line 2 in packages/gcode-bgcode/src/index.ts

View workflow job for this annotation

GitHub Actions / build

tsdoc-characters-after-block-tag: The token "@ChestnutLabs" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@"
*
* A license-clean, in-memory decoder for Prusa's binary G-code container. `.bgcode` is a container
* of ordinary G-code, so decoding it (block walk → CRC verify → decompress → decode → concatenate)
Expand All @@ -9,9 +9,9 @@
* `@chestnutlabs/toolpath-core` and `@chestnutlabs/gcode-containers` (for `crc32`/`ContainerError`);
* no `three`, framework, filesystem, or network.
*
* Phase 1: block walker + CRC32 + None/DEFLATE compression + None encoding. Phase 2: **MeatPack**
* encoding (both variants). Phase 3: **heatshrink** compression (windows 11 & 12). All spec codecs are
* now decoded; container-adapter integration + real-file golden-equivalence follow in phase 4.
* All spec codecs are decoded (None/DEFLATE/heatshrink × None/MeatPack), validated against real Prusa
* files (golden-equivalence). Phase 4c registers a **container adapter** so `.bgcode` flows through the
* parser pipeline via `containers: 'auto'`, surfacing machine geometry + settings + thumbnails.
*/
export {
BGCODE_MAGIC,
Expand All @@ -22,6 +22,9 @@
sniffBgcode,
openBgcode
} from './bgcode.js';
export type { BgcodeBlockInfo, BgcodeDecodeResult, BgcodeDecodeOptions } from './bgcode.js';
export type { BgcodeBlockInfo, BgcodeDecodeResult, BgcodeDecodeOptions, BgcodeThumbnail } from './bgcode.js';
export { meatpackDecode } from './meatpack.js';
export { heatshrinkDecode } from './heatshrink.js';
// Container-adapter integration (DD-005 §4.4, #188 phase 4c).
export { openBgcodeContainer } from './adapter.js';
export type { OpenedBgcode, BgcodeStreamLike } from './adapter.js';
3 changes: 2 additions & 1 deletion packages/gcode-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dependencies": {
"@chestnutlabs/toolpath-core": "0.2.0",
"@chestnutlabs/gcode-dialects": "0.2.0",
"@chestnutlabs/gcode-containers": "0.2.0"
"@chestnutlabs/gcode-containers": "0.2.0",
"@chestnutlabs/gcode-bgcode": "0.2.0"
}
}
Loading
Loading