From 52f59914ab09de9d7abeadb1728bdce6b75611fb Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Mon, 27 Jul 2026 19:13:22 -0700 Subject: [PATCH 1/2] feat(bgcode): container-adapter integration + worker registration + metadata (DD-011 phase 4c) (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .bgcode now flows through the existing parser pipeline: register it as a container adapter (DD-005 §4.4) beside .gcode.3mf, so `containers: 'auto'` sniffs + decodes it through GcodeParseSession — no consumer change, the whole downstream stack works unchanged. - gcode-bgcode: openBgcodeContainer(bytes) → the { id, sniff, open } shape (single plate; openPlate(0) streams the decoded gcode). openBgcode(bytes, {metadata:true}) now also decodes the metadata (INI) + thumbnail blocks, so the adapter surfaces machine geometry from bed_shape, a whitelist of slicer settings (feeds dialect detection + provenance), and thumbnails. - gcode-parser: batteries worker registers the bgcode adapter; new dep + vitest source alias. Verified end-to-end (real Prusa XL cube): parses through the session to 11,417 segments — the same count the golden-equivalence test pins against the plain .gcode — with a 360×360 bed + printer_model XL2IS metadata + 4 thumbnails. A worker without the adapter degrades honestly (binary noise → a few spurious moves, never a crash). Co-Authored-By: Claude Opus 4.8 --- .changeset/bgcode-container-adapter.md | 19 +++ package-lock.json | 1 + packages/gcode-bgcode/README.md | 11 +- .../src/__tests__/adapter.test.ts | 56 +++++++++ packages/gcode-bgcode/src/adapter.ts | 112 ++++++++++++++++++ packages/gcode-bgcode/src/bgcode.ts | 59 ++++++++- packages/gcode-bgcode/src/index.ts | 11 +- packages/gcode-parser/package.json | 3 +- .../__tests__/containers-integration.test.ts | 34 ++++++ packages/gcode-parser/src/worker.ts | 6 +- packages/gcode-parser/vitest.config.mts | 3 +- tools/pack-check-snapshots.json | 3 + 12 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 .changeset/bgcode-container-adapter.md create mode 100644 packages/gcode-bgcode/src/__tests__/adapter.test.ts create mode 100644 packages/gcode-bgcode/src/adapter.ts diff --git a/.changeset/bgcode-container-adapter.md b/.changeset/bgcode-container-adapter.md new file mode 100644 index 00000000..d97f4111 --- /dev/null +++ b/.changeset/bgcode-container-adapter.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 468ec656..33bc9b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7433,6 +7433,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "@chestnutlabs/gcode-bgcode": "0.2.0", "@chestnutlabs/gcode-containers": "0.2.0", "@chestnutlabs/gcode-dialects": "0.2.0", "@chestnutlabs/toolpath-core": "0.2.0" diff --git a/packages/gcode-bgcode/README.md b/packages/gcode-bgcode/README.md index b9c8481b..9fa7afbe 100644 --- a/packages/gcode-bgcode/README.md +++ b/packages/gcode-bgcode/README.md @@ -27,7 +27,9 @@ 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 @@ -35,10 +37,15 @@ both attributions preserved in their source files. This package is MIT. 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). diff --git a/packages/gcode-bgcode/src/__tests__/adapter.test.ts b/packages/gcode-bgcode/src/__tests__/adapter.test.ts new file mode 100644 index 00000000..c4edfb5c --- /dev/null +++ b/packages/gcode-bgcode/src/__tests__/adapter.test.ts @@ -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/); + }); +}); diff --git a/packages/gcode-bgcode/src/adapter.ts b/packages/gcode-bgcode/src/adapter.ts new file mode 100644 index 00000000..bb621886 --- /dev/null +++ b/packages/gcode-bgcode/src/adapter.ts @@ -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; + 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): 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 { + const { gcode, settings, thumbnails } = await openBgcode(bytes, { metadata: true }); + const raw: Record = {}; + 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 }; diff --git a/packages/gcode-bgcode/src/bgcode.ts b/packages/gcode-bgcode/src/bgcode.ts index 33efbf68..3206e6d7 100644 --- a/packages/gcode-bgcode/src/bgcode.ts +++ b/packages/gcode-bgcode/src/bgcode.ts @@ -56,6 +56,14 @@ 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; @@ -63,11 +71,35 @@ export interface BgcodeDecodeResult { 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; + /** 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 = { 0: 'png', 1: 'jpg', 2: 'qoi' }; + +/** Parse a PrusaSlicer-style INI block (`key = value` lines) into a record. */ +function parseIni(text: string): Record { + const out: Record = {}; + 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; @@ -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 = {}; + const thumbnails: BgcodeThumbnail[] = []; let totalOut = 0; let off = 10; @@ -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; @@ -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); @@ -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 }; } diff --git a/packages/gcode-bgcode/src/index.ts b/packages/gcode-bgcode/src/index.ts index 7761b41f..c56441ed 100644 --- a/packages/gcode-bgcode/src/index.ts +++ b/packages/gcode-bgcode/src/index.ts @@ -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, @@ -22,6 +22,9 @@ export { 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'; diff --git a/packages/gcode-parser/package.json b/packages/gcode-parser/package.json index 818cddf1..aa1e2c10 100644 --- a/packages/gcode-parser/package.json +++ b/packages/gcode-parser/package.json @@ -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" } } diff --git a/packages/gcode-parser/src/__tests__/containers-integration.test.ts b/packages/gcode-parser/src/__tests__/containers-integration.test.ts index 0ddd727e..e6fd90a9 100644 --- a/packages/gcode-parser/src/__tests__/containers-integration.test.ts +++ b/packages/gcode-parser/src/__tests__/containers-integration.test.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { openGcode3mf, sniffGcode3mf } from '@chestnutlabs/gcode-containers'; +import { openBgcodeContainer, sniffBgcode } from '@chestnutlabs/gcode-bgcode'; import { createDialectRunner, orcaBambu, prusaSlicer } from '@chestnutlabs/gcode-dialects'; import { FeatureRole } from '@chestnutlabs/toolpath-core'; import { createWorkerHandler, type WorkerHandlerOptions } from '../worker-core'; @@ -132,3 +133,36 @@ describe('.gcode.3mf through the worker pipeline (#74)', () => { session.dispose(); }); }); + +describe('.bgcode through the worker pipeline (#188)', () => { + const bgcodeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../test-data/fixtures/bgcode'); + const BGCODE: WorkerHandlerOptions['containers'] = [ + { id: 'bgcode', sniff: (prefix) => sniffBgcode(prefix), open: (bytes) => openBgcodeContainer(bytes) } + ]; + + it('sniffs + decodes a real Prusa .bgcode → the same IR as the plain .gcode + machine metadata', async () => { + const session = new GcodeParseSession({ worker: loopbackWorker({ containers: BGCODE }) }); + const bytes = new Uint8Array(fs.readFileSync(path.join(bgcodeDir, 'prim-cube.bgcode'))); + const result = await session.parse(bytes, { yieldIntervalMs: 5 }); + expect(result.ir.header.complete).toBe(true); + // Same segment count the golden-equivalence test pins against the plain .gcode. + expect(result.ir.segments.count).toBe(11417); + // Machine geometry surfaced from the container's decoded metadata blocks (bed_shape). + expect(result.metadata?.machine?.bed).toEqual({ kind: 'rect', min: { x: 0, y: 0 }, max: { x: 360, y: 360 } }); + expect(result.metadata?.machine?.printerName).toBe('XL2IS'); + expect(result.metadata?.machine?.confidence).toBe('inferred'); + expect(result.metadata?.raw?.['printer_model']).toBe('XL2IS'); + expect(result.metadata?.raw?.['Producer']).toContain('PrusaSlicer'); + session.dispose(); + }); + + it('a worker without the bgcode adapter does not decode it (honest — no adapter, no magic)', async () => { + const session = new GcodeParseSession({ worker: loopbackWorker({ containers: CONTAINERS }) }); // only 3mf + const bytes = new Uint8Array(fs.readFileSync(path.join(bgcodeDir, 'prim-cube.bgcode'))); + const result = await session.parse(bytes, { yieldIntervalMs: 5 }); + // Binary bytes fed to the raw parser → at most a few spurious moves from byte noise, nowhere near + // the real 11417-segment decode; never a crash. + expect(result.ir.segments.count).toBeLessThan(100); + session.dispose(); + }); +}); diff --git a/packages/gcode-parser/src/worker.ts b/packages/gcode-parser/src/worker.ts index d1c0d2f5..3a6b7c40 100644 --- a/packages/gcode-parser/src/worker.ts +++ b/packages/gcode-parser/src/worker.ts @@ -20,6 +20,7 @@ import { repRap } from '@chestnutlabs/gcode-dialects'; import { openGcode3mf, sniffGcode3mf } from '@chestnutlabs/gcode-containers'; +import { openBgcodeContainer, sniffBgcode } from '@chestnutlabs/gcode-bgcode'; import { createWorkerHandler, type ContainerAdapterLike, type PostFn } from './worker-core.js'; import type { WorkerRequest } from './protocol.js'; @@ -33,9 +34,10 @@ const BUILTIN_ADAPTERS: Parameters[0] = [ repRap() ]; -/** Built-in container adapters (DD-005 §4.4): .gcode.3mf. */ +/** Built-in container adapters (DD-005 §4.4): .gcode.3mf and Prusa binary G-code (.bgcode, #188). */ const BUILTIN_CONTAINERS: ContainerAdapterLike[] = [ - { id: 'gcode-3mf', sniff: sniffGcode3mf, open: (bytes) => openGcode3mf(bytes) } + { id: 'gcode-3mf', sniff: sniffGcode3mf, open: (bytes) => openGcode3mf(bytes) }, + { id: 'bgcode', sniff: (prefix) => sniffBgcode(prefix), open: (bytes) => openBgcodeContainer(bytes) } ]; declare const self: { diff --git a/packages/gcode-parser/vitest.config.mts b/packages/gcode-parser/vitest.config.mts index ab4ed7d9..92ed7605 100644 --- a/packages/gcode-parser/vitest.config.mts +++ b/packages/gcode-parser/vitest.config.mts @@ -9,7 +9,8 @@ export default defineConfig({ // alphabetical order, not dependency order). '@chestnutlabs/toolpath-core': fileURLToPath(new URL('../toolpath-core/src/index.ts', import.meta.url)), '@chestnutlabs/gcode-dialects': fileURLToPath(new URL('../gcode-dialects/src/index.ts', import.meta.url)), - '@chestnutlabs/gcode-containers': fileURLToPath(new URL('../gcode-containers/src/index.ts', import.meta.url)) + '@chestnutlabs/gcode-containers': fileURLToPath(new URL('../gcode-containers/src/index.ts', import.meta.url)), + '@chestnutlabs/gcode-bgcode': fileURLToPath(new URL('../gcode-bgcode/src/index.ts', import.meta.url)) } }, test: { diff --git a/tools/pack-check-snapshots.json b/tools/pack-check-snapshots.json index 41d4ddd5..51c2c57a 100644 --- a/tools/pack-check-snapshots.json +++ b/tools/pack-check-snapshots.json @@ -86,6 +86,9 @@ ], "gcode-bgcode": [ "README.md", + "dist/adapter.d.ts", + "dist/adapter.d.ts.map", + "dist/adapter.js", "dist/bgcode.d.ts", "dist/bgcode.d.ts.map", "dist/bgcode.js", From 5d8ce94db8792f0ef69c301f4423a5667fe51b5e Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Mon, 27 Jul 2026 19:50:55 -0700 Subject: [PATCH 2/2] fix(ci): register gcode-bgcode in the consumer-vue fixture build (#188 phase 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gcode-parser now depends on gcode-bgcode (the container adapter), so the consumer-vue reproducibility fixture must build + pack + install it too — otherwise `npm run build --workspace gcode-parser` fails with "Cannot find module '@chestnutlabs/gcode-bgcode'" (worker.ts). Add gcode-bgcode to the dependency-ordered PACKAGES list in run.mjs + run-registry.mjs (before gcode-parser) and as a file: tarball dep in the fixture app. Resolution shape now 10/10 @chestnutlabs packages from local tarballs. Co-Authored-By: Claude Opus 4.8 --- tools/consumer-vue/package-lock.json | 14 ++++++++++++++ tools/consumer-vue/package.json | 1 + tools/consumer-vue/run-registry.mjs | 1 + tools/consumer-vue/run.mjs | 1 + 4 files changed, 17 insertions(+) diff --git a/tools/consumer-vue/package-lock.json b/tools/consumer-vue/package-lock.json index 0a39f5b0..4543bc49 100644 --- a/tools/consumer-vue/package-lock.json +++ b/tools/consumer-vue/package-lock.json @@ -8,6 +8,7 @@ "name": "consumer-vue-fixture", "version": "0.0.0", "dependencies": { + "@chestnutlabs/gcode-bgcode": "file:tarballs/chestnutlabs-gcode-bgcode.tgz", "@chestnutlabs/gcode-colors": "file:tarballs/chestnutlabs-gcode-colors.tgz", "@chestnutlabs/gcode-containers": "file:tarballs/chestnutlabs-gcode-containers.tgz", "@chestnutlabs/gcode-dialects": "file:tarballs/chestnutlabs-gcode-dialects.tgz", @@ -70,6 +71,19 @@ "node": ">=6.9.0" } }, + "node_modules/@chestnutlabs/gcode-bgcode": { + "version": "0.2.0", + "resolved": "file:tarballs/chestnutlabs-gcode-bgcode.tgz", + "integrity": "sha512-IlgoPNZVjBqa6aov2wQPxiRts0JxDxrsw3QEV63vPo+1MA+0/jvW3oEou1Mn2Ii0Mx/sHsXA8pjKRcf9gsJHbA==", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-containers": "0.2.0", + "@chestnutlabs/toolpath-core": "0.2.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@chestnutlabs/gcode-colors": { "version": "0.2.0", "resolved": "file:tarballs/chestnutlabs-gcode-colors.tgz", diff --git a/tools/consumer-vue/package.json b/tools/consumer-vue/package.json index 9f7082a5..d4dd51f3 100644 --- a/tools/consumer-vue/package.json +++ b/tools/consumer-vue/package.json @@ -8,6 +8,7 @@ "test": "vitest run" }, "dependencies": { + "@chestnutlabs/gcode-bgcode": "file:tarballs/chestnutlabs-gcode-bgcode.tgz", "@chestnutlabs/gcode-colors": "file:tarballs/chestnutlabs-gcode-colors.tgz", "@chestnutlabs/gcode-containers": "file:tarballs/chestnutlabs-gcode-containers.tgz", "@chestnutlabs/gcode-dialects": "file:tarballs/chestnutlabs-gcode-dialects.tgz", diff --git a/tools/consumer-vue/run-registry.mjs b/tools/consumer-vue/run-registry.mjs index b674c855..9cdba101 100644 --- a/tools/consumer-vue/run-registry.mjs +++ b/tools/consumer-vue/run-registry.mjs @@ -27,6 +27,7 @@ const PACKAGES = [ 'gcode-colors', 'gcode-dialects', 'gcode-containers', + 'gcode-bgcode', 'gcode-parser', 'gcode-renderer-three', 'gcode-renderer-2d', diff --git a/tools/consumer-vue/run.mjs b/tools/consumer-vue/run.mjs index 9d134253..e067cacd 100644 --- a/tools/consumer-vue/run.mjs +++ b/tools/consumer-vue/run.mjs @@ -28,6 +28,7 @@ const PACKAGES = [ 'gcode-colors', 'gcode-dialects', 'gcode-containers', + 'gcode-bgcode', 'gcode-parser', 'gcode-renderer-three', 'gcode-renderer-2d',