Public: anyone can read this; the crew can change it.

A sliced .gcode file shows its toolpath one layer at a time, drawn on the server #10915

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10893-a-sliced-gcode-file-shows-its-toolpath-o → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/10893-a-sliced-gcode-file-shows-its-toolpath-o was merged into main as 9796fae.

Review

Sign in to review this change.
16 files changed +810 −7
docs/design/areas/gcode.md added +8
@@ -0,0 +1,8 @@
1 # G-code layers
2
3 The blob page's layer view (`src/web/render/gcode.ts`, styles in `gcode-css.ts`), drawn for a sliced `.gcode` file. It uses the existing tokens (`--fg`, `--border`) and the `btn` and `status-rows` classes.
4
5 - `.gcode-view` — the figure, with no box: nav row, the layer's SVG, one caption line, the facts as `status-rows`, Download.
6 - `.gcode-nav` — Previous, the layer form and Next, wrapping. At 360px the form takes its own line above, and Previous and Next share the line under it, each a `btn` (44px tall, so a thumb hits it). From 640px the three sit in one row. A missing neighbour is a `btn` with `aria-disabled`, so the row keeps its shape.
7 - `.gcode-form` — the `Layer [n] of N` label, a number input (6em, 44px tall) and Show. A plain GET form: it works without JavaScript.
8 - The SVG fills the width at its own aspect ratio and is held under 70% of the screen's height; its `viewBox` is set on the server, so nothing shifts.
docs/features/gcode.md added +24
@@ -0,0 +1,24 @@
1 # G-code layers
2
3 A sliced `.gcode` (or `.gco`) file opens in the file view as its toolpath, one layer at a time. The drawing is SVG made on the server from numbers only; nothing in the file's text reaches the page.
4
5 ## On the web
6
7 Open the file. **Previous** and **Next** step through the layers; the box jumps to one (`?layer=N`, so the URL reproduces the view). Solid lines extrude, faint lines travel. Under the drawing: layers, bounding box (mm), filament used (mm of filament) and the slicer's own time estimate when the file states one.
8
9 - A number past the last layer shows the last; anything that is not a whole number shows layer 1.
10 - A layer with more than 20,000 moves draws the first 20,000 and says so.
11 - Over 50 MB the layers are not drawn: the page says it is G-code and how big, and offers Download.
12 - Files over 256 KB are parsed on a worker thread, and the parse is kept for the next page of the same file. If the worker cannot answer, the page says to try again.
13
14 ## What is read
15
16 G0/G1 moves (G2/G3 as the straight line to their endpoint), G90/G91, M82/M83 and G92. A move that extrudes (E goes forward while X or Y changes) belongs to the layer of its Z, rounded to a micron.
17
18 Filament is the forward travel of E; a retraction's rewind is paid back before more forward travel counts, so retract 1 mm and unretract 1 mm adds nothing.
19
20 **Units are millimetres.** G21 is the slicer default; a file in inches (G20) is not converted, and its numbers are drawn as if they were millimetres.
21
22 ## In the terminal
23
24 Open the file from a repository in `forge` over SSH: it shows the same facts as a table (layers, bounding box, filament, estimated time). The layer drawing is a web view.
src/tui/screens/workspace.ts +15
@@ -57,6 +57,7 @@ import type { RepoFacts, RepoSource } from "../../view/source.ts";
5757 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5858 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
5959 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
60 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6061 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6162 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6263 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
@@ -375,6 +376,17 @@ function gpxTable(source: string, path: string): Table | null {
375376 return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
376377 }
377378
379 /** `.gcode`: a sliced print, drawn as a table of its facts (layers, box, filament, time). Shares the web's parser. */
380 function isGcodePath(path: string): boolean {
381 return /\.(gcode|gco)$/i.test(path);
382 }
383
384 function gcodeTable(source: string): Table | null {
385 const parsed = parseGcode(source);
386 if (parsed === undefined) return null;
387 return { header: ["Fact", "Value"], rows: gcodeFacts(parsed).map(([k, v]) => [k, v]), more: 0, delimiter: ",", columns: 2 };
388 }
389
378390 /** `.geojson`: features, drawn as a table of their geometry type and properties. */
379391 function isGeoJsonPath(path: string): boolean {
380392 return /\.geojson$/i.test(path);
@@ -553,6 +565,9 @@ class Tab {
553565 } else if (isGpxPath(this.path)) {
554566 this.table = gpxTable(new TextDecoder().decode(source), this.path);
555567 if (this.table !== null) this.grid = layoutTable(this.table);
568 } else if (isGcodePath(this.path)) {
569 this.table = gcodeTable(new TextDecoder().decode(source));
570 if (this.table !== null) this.grid = layoutTable(this.table);
556571 } else if (isGeoJsonPath(this.path)) {
557572 this.table = geoJsonTable(new TextDecoder().decode(source));
558573 if (this.table !== null) this.grid = layoutTable(this.table);
src/view/local.ts +4 −2
@@ -43,7 +43,7 @@ import {
4343 import { repoDir } from "../git/repos.ts";
4444 import { err, ok, type Result } from "../kernel/result.ts";
4545 import type { Page } from "../origins/types.ts";
46 import { HEAD_BYTES } from "../web/render/kinds.ts";
46 import { HEAD_BYTES, RENDER_CAP } from "../web/render/kinds.ts";
4747 import type {
4848 Blob,
4949 Branch,
@@ -61,6 +61,8 @@ import type {
6161
6262 /** The same ceiling the contents route uses; a page is a page. */
6363 const MAX_BLOB_BYTES = 5_000_000;
64 /** A sliced G-code file is read whole, up to the cap its renderer draws. */
65 const blobCeiling = (path: string): number => (/\.(gcode|gco)$/i.test(path) ? RENDER_CAP.gcode : MAX_BLOB_BYTES);
6466 const COMMIT_PAGE = 50;
6567
6668 export function localSource(
@@ -201,7 +203,7 @@ export function localSource(
201203
202204 async blob(ref: string, path: string): Promise<Result<Blob, SourceError>> {
203205 if (!isSafeRef(ref) || !isSafeTreePath(path)) return err("not_found");
204 const blob = await blobForRender(dir, ref, path, MAX_BLOB_BYTES);
206 const blob = await blobForRender(dir, ref, path, blobCeiling(path));
205207 if (blob.kind === "missing") return err("not_found");
206208 if (blob.kind !== "too_large") return ok(blob);
207209 // Too large to read, but its first bytes still say what it is.
src/web/code_rows_pool.ts +9 −2
@@ -8,6 +8,7 @@
88 import { availableParallelism } from "node:os";
99 import type { Encoding } from "../http/compress.ts";
1010 import type { CodeRows } from "./code_rows.ts";
11 import type { Gcode } from "./render/gcode.ts";
1112 import type { Job } from "./code_rows_worker.ts";
1213
1314 /** The floor: one worker busy on a long file must not stall every cold page. */
@@ -49,6 +50,8 @@ export function poolSize(
4950
5051 const WORKERS = poolSize();
5152 const JOB_MS = 5_000;
53 /** A 50 MB sliced file takes longer than a page of code. */
54 const GCODE_JOB_MS = 30_000;
5255 /** Jobs in flight past which the caller renders inline instead of queueing. */
5356 const MAX_PENDING = 64;
5457
@@ -86,7 +89,7 @@ function start(): Worker | undefined {
8689
8790 type Spec = Job extends infer J ? (J extends Job ? Omit<J, "id"> : never) : never;
8891
89 function run<T>(job: Spec): Promise<T | undefined> {
92 function run<T>(job: Spec, ms: number = JOB_MS): Promise<T | undefined> {
9093 if (waiting.size >= MAX_PENDING) return Promise.resolve(undefined);
9194 if (pool.length < WORKERS) {
9295 const w = start();
@@ -99,7 +102,7 @@ function run<T>(job: Spec): Promise<T | undefined> {
99102 const timer = setTimeout(() => {
100103 waiting.delete(id);
101104 resolve(undefined);
102 }, JOB_MS);
105 }, ms);
103106 waiting.set(id, {
104107 worker,
105108 done: (result) => {
@@ -121,3 +124,7 @@ export const encodeOffThread = (body: Uint8Array<ArrayBuffer>, encoding: Encodin
121124
122125 /** Test seam: the live workers. */
123126 export const workersForTest = (): readonly Worker[] => pool;
127
128 /** A G-code file parsed on a worker (up to `GCODE_JOB_MS`): a parse, null for none, undefined when the caller should not wait. */
129 export const gcodeOffThread = async (body: Uint8Array): Promise<{ parsed: Gcode | null } | undefined> =>
130 run<{ parsed: Gcode | null }>({ kind: "gcode", body }, GCODE_JOB_MS);
src/web/code_rows_worker.ts +4
@@ -1,11 +1,13 @@
11 /** The worker side of `code_rows_pool.ts`: lay out rows, or encode a finished page. */
22 import { compressBytes, type Encoding } from "../http/compress.ts";
3 import { parseGcode } from "./render/gcode.ts";
34 import { codeRows } from "./code_rows.ts";
45
56 declare const self: Worker;
67
78 export type Job =
89 | { id: number; kind: "rows"; body: Uint8Array; path: string; limit: number }
10 | { id: number; kind: "gcode"; body: Uint8Array }
911 | { id: number; kind: "encode"; body: Uint8Array<ArrayBuffer>; encoding: Encoding };
1012
1113 self.onmessage = (e: MessageEvent<Job>) => {
@@ -14,6 +16,8 @@ self.onmessage = (e: MessageEvent<Job>) => {
1416 if (job.kind === "rows") {
1517 const text = new TextDecoder().decode(job.body).replace(/\n$/, "");
1618 postMessage({ id: job.id, result: codeRows(text, job.path, job.limit) });
19 } else if (job.kind === "gcode") {
20 postMessage({ id: job.id, result: { parsed: parseGcode(new TextDecoder().decode(job.body)) ?? null } });
1721 } else {
1822 postMessage({ id: job.id, result: compressBytes(job.body, job.encoding) });
1923 }
src/web/pages/repo.ts +24 −3
@@ -82,12 +82,13 @@ import { field, readForm } from "../form.ts";
8282 import { html, raw, when, type Html } from "../html.ts";
8383 import { creditLine } from "../credit_line.ts";
8484 import { type CodeRows, rowsMarkup } from "../code_rows.ts";
85 import { rowsOffThread } from "../code_rows_pool.ts";
85 import { gcodeOffThread, rowsOffThread } from "../code_rows_pool.ts";
8686 import { flashTo, fragment, page, redirect } from "../layout.ts";
8787 import { renderMarkdown } from "../markdown.ts";
8888 import { overSize } from "../render/archive.ts";
8989 import { csvJs, isCsvPath } from "../render/csv.ts";
9090 import { fontJs } from "../render/font.ts";
91 import { INLINE_MAX, keepGcode, keptGcode, layerCount } from "../render/gcode.ts";
outdated

layerCount parses the whole file (up to 50 MB) synchronously on the request path, before render. Move parsing to a worker or bound it; box 4 asks for the blob page budget.

9192 import { hasTable } from "../render/sqlite.ts";
9293 import { fileKind, HEAD_BYTES, kindByName, RENDER_CAP } from "../render/kinds.ts";
9394 import { RENDERERS, rendererFor } from "../render/renderers.ts";
@@ -1100,6 +1101,8 @@ function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<ty
11001101 function overCap(path: string, blob: Blob): string | undefined {
11011102 if (kindByName(path) === "notebook" && blob.size > RENDER_CAP.notebook)
11021103 return `This notebook is over ${RENDER_CAP.notebook / 1_000_000} MB, so it is not drawn as cells${blob.kind === "too_large" ? ". Download it to read it" : " and is shown as plain text"}.`;
1104 if (kindByName(path) === "gcode" && blob.size > RENDER_CAP.gcode)
1105 return `G-code, over the ${fileSize(RENDER_CAP.gcode)} the page draws, so its layers are not drawn. Download it to read it.`;
11031106 if (blob.kind !== "binary" || fileKind(path, blob.body.subarray(0, HEAD_BYTES)) !== "archive") return undefined;
11041107 return blob.size > RENDER_CAP.archive ? overSize(RENDER_CAP.archive) : undefined;
11051108 }
@@ -1141,6 +1144,8 @@ export function renderBlob(
11411144 all?: boolean;
11421145 /** `?table=` on a SQLite file. */
11431146 table?: string;
1147 /** `?layer=` on a G-code file. */
1148 layer?: number;
11441149 /** Rows a worker already built for this file. */
11451150 rows?: CodeRows;
11461151 },
@@ -1161,6 +1166,7 @@ export function renderBlob(
11611166 pathHref: `${base}/blob/${encoded}/${o.path}`,
11621167 all: o.all === true,
11631168 ...(o.table !== undefined ? { table: o.table } : {}),
1169 ...(o.layer !== undefined ? { layer: o.layer } : {}),
11641170 })
11651171 : undefined;
11661172 const fellBack = drawn?.fallback;
@@ -1662,6 +1668,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16621668 const view = blobView(ctx.url.searchParams.get("view"));
16631669 const all = ctx.url.searchParams.get("all") === "1";
16641670 const table = ctx.url.searchParams.get("table")?.slice(0, 256) ?? undefined;
1671 const layerText = ctx.url.searchParams.get("layer");
1672 const layer = layerText !== null && /^\d{1,6}$/.test(layerText) ? Number(layerText) : undefined;
16651673 // The whole page as it last went out to a stranger, encoded (`kept_page.ts`).
16661674 const keptKey = kept ? keptPageKey(ctx, repo.id) : undefined;
16671675 const finished = keptKey === undefined ? undefined : keptPage(keptKey);
@@ -1673,7 +1681,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16731681 let keepable = true;
16741682 const keepFinished = (response: Response) => (keptKey === undefined || !keepable ? response : keepPage(keptKey, response, true));
16751683 if (kept) {
1676 const hit = cachedBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all);
1684 const hit = cachedBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}\0${layer ?? ""}`, all);
16771685 if (hit !== undefined) {
16781686 git.done();
16791687 return immutableWhenAddressed(
@@ -1701,6 +1709,16 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17011709 const renderStart = performance.now();
17021710 const shown = capped(blob.value, path);
17031711 if (table !== undefined) keepable = shown.kind !== "too_large" && hasTable(shown.body, table);
1712 // A big sliced file is parsed on a worker, never on this thread; the renderer finds the parse kept.
1713 if (shown.kind !== "too_large" && shown.body.length > INLINE_MAX && kindByName(path) === "gcode" && keptGcode(shown.body) === undefined) {
1714 const began = performance.now();
1715 const made = await gcodeOffThread(shown.body);
1716 if (made !== undefined) keepGcode(shown.body, made.parsed ?? undefined);
1717 notePhase(ctx.request, "parse", performance.now() - began);
1718 }
1719 // A `?layer=` past the last layer is a stranger's number: its page is never kept.
1720 if (layer !== undefined && keepable)
1721 keepable = shown.kind !== "too_large" && layer >= 1 && layer <= layerCount(shown.body);
17041722 // A cold page for a stranger highlights and lays out its rows on a worker (#9630).
17051723 let rows: CodeRows | undefined;
17061724 if (kept && shown.kind === "text" && (view === "source" || !blobRenderer(scope, path, shown))) {
@@ -1718,6 +1736,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17181736 highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms),
17191737 all,
17201738 ...(table !== undefined ? { table } : {}),
1739 ...(layer !== undefined ? { layer } : {}),
17211740 });
17221741 if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs);
17231742 const paneMs = performance.now() - renderStart - (highlightMs ?? 0);
@@ -1747,7 +1766,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17471766 const objectSrc = drawsPdf(scope, path, shown, view);
17481767 const font = drawsFont(scope, path, shown, view);
17491768 if (kept && keepable)
1750 keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, {
1769 keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}\0${layer ?? ""}`, all, {
17511770 title: `${filename} · ${repo.name}`,
17521771 explorer: explorerHtml,
17531772 content: pane,
@@ -1798,6 +1817,8 @@ export function capped(blob: Blob, path = ""): Blob {
17981817 // A SQLite file is read whole, up to its own cap, because the database is opened from the bytes.
17991818 if (blob.kind !== "too_large" && fileKind(path, blob.body.subarray(0, HEAD_BYTES)) === "sqlite" && blob.size <= RENDER_CAP.sqlite)
18001819 return blob;
1820 if (blob.kind !== "too_large" && blob.size <= RENDER_CAP.gcode && fileKind(path, blob.body.subarray(0, HEAD_BYTES)) === "gcode")
1821 return blob;
18011822 return blob.kind !== "too_large" && blob.size > MAX_RENDER_BYTES
18021823 ? { kind: "too_large", size: blob.size, head: blob.body.slice(0, HEAD_BYTES) }
18031824 : blob;
src/web/render/gcode-css.ts added +17
@@ -0,0 +1,17 @@
1 /** The G-code layer view's styles, apart from `gcode.ts` so `style.ts` imports no renderer. */
2
3 /** The nav row wraps at 360px: the form takes its own line, Previous and Next share the one under it. */
4 export const gcodeCss = /* css */ `
5 @layer content {
6 .gcode-view { margin: 0; }
7 .gcode-nav { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; }
8 .gcode-nav > .btn { min-height: 44px; }
9 .gcode-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; order: -1; flex: 1 1 100%; }
10 .gcode-form input { width: 6em; min-height: 44px; }
11 .gcode-form .btn { min-height: 44px; }
12 .gcode-view svg { display: block; width: 100%; height: auto; max-height: 70dvh; color: var(--fg); }
13 @media (min-width: 640px) {
14 .gcode-form { order: 0; flex: 0 1 auto; }
15 }
16 }
17 `;
src/web/render/gcode.ts added +369
@@ -0,0 +1,369 @@
1 /**

Missing: docs/features/gcode.md, a src/tui/ screen, and docs/design/areas entry for gcode-view/gcode-nav/gcode-form (with CSS; check the 360px nav row). Design principles 1 and 8: the caption explains itself twice.

2 * A sliced `.gcode` file drawn one layer at a time, as SVG made on the server.
3 *
4 * The parser reads the linear moves a slicer writes (G0/G1; G2/G3 are taken
5 * as the straight line to their endpoint), honours G90/G91 (positioning),
6 * M82/M83 (extruder mode) and G92 (set position), and groups the moves that
7 * extrude into layers by Z. Units are millimetres (G21, the slicer default;
8 * G20 inches are not converted, see docs/features/gcode.md). Nothing from the file reaches the page but
9 * numbers: comments are read only for a time estimate and never echoed.
10 *
11 * Filament used (mm of filament, the E axis): the sum of forward E travel,
12 * with a retraction's rewind paid back before further forward travel counts.
13 * So a retract of 1 mm followed by an unretract of 1 mm adds nothing, and
14 * 20 mm of E in 3 layers is 20 mm. This is the same for absolute and
15 * relative extruder modes.
16 *
17 * A parse is kept by the file's size and content hash,
18 * so paging through layers parses once.
19 */
20
21 import { html, raw } from "../html.ts";
22 import { fileSize } from "../ui.ts";
23 import type { Rendered, RenderInput } from "./renderers.ts";
24
25 /** Segments one layer's drawing holds; more are left out and the page says so. */
26 export const SEGMENT_CAP = 20_000;
27
28 export interface Layer {
29 /** Z in mm of the extruding moves. */
30 readonly z: number;
31 /** x0, y0, x1, y1, extrudes (1) or travels (0), per move. */
32 readonly moves: Float32Array;
33 }
34 export interface Gcode {
35 readonly layers: readonly Layer[];
36 /** x, y, z extents of the extruding moves, in mm. */
37 readonly min: readonly [number, number, number];
38 readonly max: readonly [number, number, number];
39 /** Millimetres of filament. */
40 readonly filament: number;
41 /** The slicer's own estimate in seconds, when a comment states one. */
42 readonly seconds?: number;
43 }
44
45 const WORD = /([A-Za-z])\s*([+-]?(?:\d+\.?\d*|\.\d+))/g;
46 const SECONDS = /^\s*TIME\s*:\s*(\d+(?:\.\d+)?)\s*$/i;
47 const ESTIMATE = /(?:estimated printing time|total estimated time)[^=:]*[=:]\s*((?:\d+\s*[dhms]\s*)+)$/i;
48
49 /** `1d 2h 3m 4s` as seconds. */
50 function duration(text: string): number {
51 let s = 0;
52 for (const m of text.matchAll(/(\d+)\s*([dhms])/gi))
53 s += Number(m[1]) * ({ d: 86400, h: 3600, m: 60, s: 1 }[m[2]!.toLowerCase() as "d"] ?? 0);
54 return s;
55 }
56
57 /** Moves as five floats each in one typed array that doubles as it fills: 20 bytes a move, no boxed numbers. */
58 class Moves {
59 private a = new Float32Array(5 * 64);
60 private n = 0;
61 push(x0: number, y0: number, x1: number, y1: number, k: number): void {
62 if (this.n + 5 > this.a.length) this.grow(this.n + 5);
63 const a = this.a;
64 a[this.n] = x0; a[this.n + 1] = y0; a[this.n + 2] = x1; a[this.n + 3] = y1; a[this.n + 4] = k;
65 this.n += 5;
66 }
67 private grow(need: number): void {
68 const b = new Float32Array(Math.max(need, this.a.length * 2));
69 b.set(this.a.subarray(0, this.n));
70 this.a = b;
71 }
72 /** Add another run's moves after these (a loop of copies, never a spread of arguments). */
73 append(other: Moves): void {
74 if (other.n === 0) return;
75 if (this.n + other.n > this.a.length) this.grow(this.n + other.n);
76 this.a.set(other.a.subarray(0, other.n), this.n);
77 this.n += other.n;
78 }
79 /** Past twice `moves` moves, forget all but the last `moves` (amortised: one copy per `moves` pushes). */
80 keepLast(moves: number): void {
81 if (this.n <= 10 * moves) return;
82 this.a.copyWithin(0, this.n - 5 * moves, this.n);
83 this.n = 5 * moves;
84 }
85 done(): Float32Array {
86 return this.a.slice(0, this.n);
87 }
88 }
89
90 /** Parse G-code text. Pure. Undefined when no move in it extrudes. */
91 export function parseGcode(text: string): Gcode | undefined {
92 let x = 0, y = 0, z = 0, e = 0;
93 let absolute = true;
94 let absoluteE = true;
95 let retracted = 0;
96 let filament = 0;
97 let seconds: number | undefined;
98 const layers: Layer[] = [];
99 let layerZ: number | undefined;
100 let cur = new Moves();
101 let pending = new Moves();
102 const min = [Infinity, Infinity, Infinity];
103 const max = [-Infinity, -Infinity, -Infinity];
104
105 const close = (): void => {
106 if (layerZ !== undefined) layers.push({ z: layerZ, moves: cur.done() });
107 cur = new Moves();
108 };
109
110 let pos = 0;
111 const n = text.length;
112 while (pos < n) {
113 let end = text.indexOf("\n", pos);
114 if (end < 0) end = n;
115 let line = text.slice(pos, end);
116 pos = end + 1;
117 const semi = line.indexOf(";");
118 if (semi >= 0) {
119 const comment = line.slice(semi + 1);
120 line = line.slice(0, semi);
121 if (seconds === undefined) {
122 const t = SECONDS.exec(comment);
123 if (t !== null) seconds = Number(t[1]);
124 else {
125 const p = ESTIMATE.exec(comment.trim());
126 if (p !== null) seconds = duration(p[1]!);
127 }
128 }
129 }
130 const star = line.indexOf("*");
131 if (star >= 0) line = line.slice(0, star);
132 line = line.trim();
133 const c0 = line.charCodeAt(0);
134 // Only G and M words (either case), after an optional N line number.
135 if (c0 !== 71 && c0 !== 103 && c0 !== 77 && c0 !== 109 && c0 !== 78 && c0 !== 110) continue;
136
137 let code = "";
138 const v: Record<string, number> = {};
139 for (const m of line.matchAll(WORD)) {
140 const letter = m[1]!.toUpperCase();
141 const value = Number(m[2]);
142 if (!Number.isFinite(value)) continue;
143 if ((letter === "G" || letter === "M") && code === "") code = letter + String(Math.trunc(value));
144 else if (letter !== "N") v[letter] = value;
145 }
146 switch (code) {
147 case "G90": absolute = true; break;
148 case "G91": absolute = false; break;
149 case "M82": absoluteE = true; break;
150 case "M83": absoluteE = false; break;
151 case "G92":
152 if (v["X"] !== undefined) x = v["X"];
153 if (v["Y"] !== undefined) y = v["Y"];
154 if (v["Z"] !== undefined) z = v["Z"];
155 if (v["E"] !== undefined) e = v["E"];
156 break;
157 case "G0":
158 case "G1":
159 case "G2":
160 case "G3": {
161 const nx = v["X"] === undefined ? x : absolute ? v["X"] : x + v["X"];
162 const ny = v["Y"] === undefined ? y : absolute ? v["Y"] : y + v["Y"];
163 const nz = v["Z"] === undefined ? z : absolute ? v["Z"] : z + v["Z"];
164 let de = 0;
165 if (v["E"] !== undefined) {
166 if (absoluteE) {
167 de = v["E"] - e;
168 e = v["E"];
169 } else de = v["E"];
170 }
171 const moved = nx !== x || ny !== y;
172 if (de < 0) retracted -= de;
173 let forward = 0;
174 if (de > 0) {
175 const paid = Math.min(retracted, de);
176 retracted -= paid;
177 forward = de - paid;
178 }
179 const extrudes = de > 0 && moved;
180 // Z is rounded to the micron so float noise does not make a layer.
181 const zr = Math.round(nz * 1000) / 1000;
182 if (extrudes) {
183 filament += forward;
184 if (layerZ === undefined || zr !== layerZ) {
185 close();
186 layerZ = zr;
187 // The travel that led here belongs to the layer it arrives in.
188 cur = pending;
189 } else cur.append(pending);
190 pending = new Moves();
191 cur.push(x, y, nx, ny, 1);
192 min[0] = Math.min(min[0]!, x, nx);
193 min[1] = Math.min(min[1]!, y, ny);
194 min[2] = Math.min(min[2]!, zr);
195 max[0] = Math.max(max[0]!, x, nx);
196 max[1] = Math.max(max[1]!, y, ny);
197 max[2] = Math.max(max[2]!, zr);
198 } else {
199 filament += de > 0 ? forward : 0;
200 if (moved && layerZ !== undefined) {
201 pending.push(x, y, nx, ny, 0);
202 // A long travel run before the next extrusion is bounded.
203 pending.keepLast(SEGMENT_CAP);
204 }
205 }
206 x = nx;
207 y = ny;
208 z = nz;
209 break;
210 }
211 }
212 }
213 close();
214 if (layers.length === 0) return undefined;
215 return {
216 layers,
217 min: min as [number, number, number],
218 max: max as [number, number, number],
219 filament,
220 ...(seconds !== undefined ? { seconds } : {}),
221 };
222 }
223
224 const kept = new Map<string, Gcode | null>();
225 const KEEP = 4;
226 /** Larger than this and the parse belongs on a worker, not the request thread. */
227 export const INLINE_MAX = 256 * 1024;
228
229 /**
230 * The key of a body: its size and a 64-bit wyhash (about 1 GB/s, so 50 MB is
231 * tens of milliseconds where a SHA-1 would be several times that). A parse is
232 * only a picture of the file, and a collision needs two files of one size.
233 */
234 const keyOf = (body: Uint8Array): string => `${body.length}:${Bun.hash.wyhash(body).toString(16)}`;
235
236 /** A kept parse: the Gcode, null when the file has no moves, undefined when not parsed yet. */
237 export function keptGcode(body: Uint8Array): Gcode | null | undefined {
238 const id = keyOf(body);
239 const hit = kept.get(id);
240 if (hit !== undefined) {
241 kept.delete(id);
242 kept.set(id, hit);
243 }
244 return hit;
245 }
246
247 /** Keep a parse (made on a worker or here) for the next page of the same file. */
248 export function keepGcode(body: Uint8Array, parsed: Gcode | undefined): void {
249 kept.set(keyOf(body), parsed ?? null);
250 if (kept.size > KEEP) kept.delete(kept.keys().next().value!);
251 }
252
253 /** The parse of a body, once per content. Inline: callers with a big body use `gcodeOffThread` first. */
254 export function parsedGcode(body: Uint8Array): Gcode | undefined {
255 const hit = keptGcode(body);
256 if (hit !== undefined) return hit ?? undefined;
257 const parsed = parseGcode(new TextDecoder().decode(body));
258 keepGcode(body, parsed);
259 return parsed;
260 }
261
262 /**
263 * How many layers a body has, for a page that must know a `?layer=` is real.
264 * A body not yet parsed and too big to parse here counts as none.
265 */
266 export function layerCount(body: Uint8Array): number {
267 const hit = keptGcode(body);
268 if (hit === undefined && body.length > INLINE_MAX) return 0;
269 return (hit === undefined ? parsedGcode(body) : (hit ?? undefined))?.layers.length ?? 0;
270 }
271
272 const mm = (n: number): string => (Math.round(n * 100) / 100).toLocaleString("en", { maximumFractionDigits: 2 });
273 const f2 = (n: number): string => (Math.round(n * 100) / 100).toString();
274
275 /** `1 h 02 min`, `4 min 05 s`, `38 s`. */
276 export function clock(seconds: number): string {
277 const s = Math.round(seconds);
278 const h = Math.floor(s / 3600);
279 const m = Math.floor((s % 3600) / 60);
280 if (h > 0) return `${h} h ${String(m).padStart(2, "0")} min`;
281 if (m > 0) return `${m} min ${String(s % 60).padStart(2, "0")} s`;
282 return `${s} s`;
283 }
284
285 /** Facts as label and text, shared by the page and the tests. */
286 export function gcodeFacts(g: Gcode): [string, string][] {
287 const [w, d, h] = [g.max[0] - g.min[0], g.max[1] - g.min[1], g.max[2]];
288 const facts: [string, string][] = [
289 ["Layers", g.layers.length.toLocaleString("en")],
290 ["Bounding box", `${mm(w)} × ${mm(d)} × ${mm(h)} mm`],
291 ["Filament", `${mm(g.filament)} mm`],
292 ];
293 if (g.seconds !== undefined) facts.push(["Estimated time", clock(g.seconds)]);
294 return facts;
295 }
296
297 const W = 640;
298 const MAX_H = 480;
299 const PAD = 8;
300
301 /** One layer as an SVG: extrusion bold, travel faint. Numbers only. */
302 export function layerSvg(g: Gcode, index: number): { svg: string; shown: number; total: number } {
303 const layer = g.layers[index]!;
304 const spanX = Math.max(g.max[0] - g.min[0], 0.1);
305 const spanY = Math.max(g.max[1] - g.min[1], 0.1);
306 let scale = (W - 2 * PAD) / spanX;
307 if (spanY * scale + 2 * PAD > MAX_H) scale = (MAX_H - 2 * PAD) / spanY;
308 const width = Math.round(spanX * scale + 2 * PAD);
309 const height = Math.round(spanY * scale + 2 * PAD);
310 const px = (v: number): string => f2(PAD + (v - g.min[0]) * scale);
311 const py = (v: number): string => f2(PAD + (g.max[1] - v) * scale);
312 const total = layer.moves.length / 5;
313 const shown = Math.min(total, SEGMENT_CAP);
314 let bold = "";
315 let faint = "";
316 let lx = NaN, ly = NaN, lk = -1;
317 for (let i = 0; i < shown; i++) {
318 const o = i * 5;
319 const [x0, y0, x1, y1, k] = [layer.moves[o]!, layer.moves[o + 1]!, layer.moves[o + 2]!, layer.moves[o + 3]!, layer.moves[o + 4]!];
320 // A move that starts where the last ended, of the same kind, continues its path.
321 const join = k === lk && Math.abs(x0 - lx) < 1e-3 && Math.abs(y0 - ly) < 1e-3;
322 const seg = `${join ? "L" : `M${px(x0)} ${py(y0)}L`}${px(x1)} ${py(y1)}`;
323 if (k === 1) bold += seg;
324 else faint += seg;
325 lx = x1; ly = y1; lk = k;
326 }
327 const svg =
328 `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Layer ${index + 1} of ${g.layers.length}, Z ${mm(layer.z)} mm" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
329 `<path d="${faint}" stroke="currentColor" stroke-opacity="0.25" stroke-width="0.75"/>` +
330 `<path d="${bold}" stroke="currentColor" stroke-width="${f2(Math.min(3, Math.max(1, 0.4 * scale)))}"/></svg>`;
331 return { svg, shown, total };
332 }
333
334 export function gcode(input: RenderInput): Rendered {
335 const hit = keptGcode(input.body);
336 if (hit === undefined && input.body.length > INLINE_MAX)
337 return { content: html``, lines: 0, fallback: "This file is too big to draw while the forge is busy; try again, or download it." };
338 const g = hit === undefined ? parsedGcode(input.body) : (hit ?? undefined);
339 if (g === undefined) return { content: html``, lines: 0, fallback: "This file has no extruding moves to draw." };
340 const count = g.layers.length;
341 const want = input.layer !== undefined && Number.isInteger(input.layer) ? input.layer : 1;
342 const n = Math.min(count, Math.max(1, want));
343 const { svg, shown, total } = layerSvg(g, n - 1);
344 const facts = gcodeFacts(g);
345 const href = input.pathHref ?? "";
346 const nav = (to: number, label: string, on: boolean) =>
347 on
348 ? html`<a class="btn" href="${href}?layer=${to}" rel="${to < n ? "prev" : "next"}">${label}</a>`
349 : html`<span class="btn" aria-disabled="true">${label}</span>`;
350 const content = html`<figure class="gcode-view">
351 <div class="gcode-nav">
352 ${nav(n - 1, "← Previous", n > 1)}
353 <form method="get" action="${href}" class="gcode-form">
354 <label>Layer <input type="number" name="layer" min="1" max="${count}" value="${n}" inputmode="numeric" required> of ${count.toLocaleString("en")}</label>
355 <button type="submit" class="btn">Show</button>
356 </form>
357 ${nav(n + 1, "Next →", n < count)}
358 </div>
359 ${raw(svg)}
360 <p class="muted small">Layer ${n.toLocaleString("en")} · Z ${mm(g.layers[n - 1]!.z)} mm: solid lines extrude, faint lines travel.${shown < total ? ` Drawing the first ${shown.toLocaleString("en")} of ${total.toLocaleString("en")} moves.` : ""}</p>
361 <dl class="status-rows">${facts.map(([k, v]) => html`<div class="status-row"><dt>${k}</dt><dd>${v}</dd></div>`)}</dl>
362 <p class="small"><a href="${input.download}">Download</a></p>
363 </figure>`;
364 return {
365 content,
366 lines: 0,
367 summary: `${count.toLocaleString("en")} layers · ${mm(g.filament / 1000)} m filament · ${fileSize(input.size)}`,
368 };
369 }
src/web/render/kinds.ts +4
@@ -33,6 +33,7 @@ export type FileKind =
3333 | "geojson"
3434 | "calendar"
3535 | "contacts"
36 | "gcode"
3637 | "text"
3738 | "binary";
3839
@@ -65,6 +66,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
6566 geojson: 5 * MB,
6667 calendar: 5 * MB,
6768 contacts: 5 * MB,
69 gcode: 50 * MB,
6870 text: 1 * MB,
6971 binary: 0,
7072 };
@@ -178,6 +180,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
178180 ics: "calendar",
179181 vcf: "contacts",
180182 vcard: "contacts",
183 gcode: "gcode",
184 gco: "gcode",
181185 };
182186
183187 /**
src/web/render/renderers.ts +4
@@ -12,6 +12,7 @@ import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { csv } from "./csv.ts";
1414 import { geojson } from "./geojson.ts";
15 import { gcode } from "./gcode.ts";
1516 import { gpx } from "./gpx.ts";
1617 import { font } from "./font.ts";
1718 import { ics } from "./ics.ts";
@@ -47,6 +48,8 @@ export interface RenderInput {
4748 readonly all?: boolean;
4849 /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */
4950 readonly table?: string;
51 /** `?layer=N` (1-based) on a G-code file; the renderer clamps it to the layers it has. */
52 readonly layer?: number;
5053 }
5154
5255 export interface Rendered {
@@ -77,6 +80,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
7780 log,
7881 subtitles,
7982 gpx,
83 gcode,
8084 geojson,
8185 calendar: ics,
8286 contacts: vcf,
src/web/style.ts +2
@@ -27,6 +27,7 @@ import { boardCss } from "./pages/board_style.ts";
2727 import { base, layers, reset, utilities } from "./style/base.ts";
2828 import { components } from "./style/components.ts";
2929 import { content } from "./style/content.ts";
30 import { gcodeCss } from "./render/gcode-css.ts";
3031 import { model3dCss } from "./render/model3d-css.ts";
3132 import { pickCss } from "./pick.ts";
3233 import { studioCss } from "./studio/style.ts";
@@ -41,6 +42,7 @@ export const css = [
4142 boardCss,
4243 content,
4344 model3dCss,
45 gcodeCss,
4446 utilities,
4547 swatchCss,
4648 pickCss,
test/gcode-page.test.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * The G-code layer view on the blob page: `?layer=` read at the boundary, the
3 * 50 MB cap and the local source's own ceiling for the kind, and a big file's
4 * parse made off the request thread.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { localSource } from "../src/view/local.ts";
16 import { INLINE_MAX, keptGcode, layerCount, parseGcode } from "../src/web/render/gcode.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { setCrawlClock } from "../src/http/crawl.ts";
19 import { setStrangerLimits, strangerLimits } from "../src/http/strangers.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 const MB = 1_000_000;
26 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
27
28 /** `layers` layers, each of `per` extruding moves; about 40 bytes a move. */
29 const sliced = (layers: number, per: number): string => {
30 const out = ["G21", "G90", "M83"];
31 for (let l = 1; l <= layers; l++) {
32 out.push(`G1 Z${(l * 0.2).toFixed(1)}`);
33 for (let i = 0; i < per; i++) out.push(`G1 X${(i % 90) + 5}.123 Y${((i * 7) % 90) + 5}.456 E0.01234 F1800`);
34 }
35 return out.join("\n") + "\n";
36 };
37
38 const SMALL = enc(sliced(3, 40));
39 /** Past the source's 5 MB fetch ceiling and the inline parse, under the 50 MB cap. */
40 const MID = enc(sliced(4, 40_000));
41 /** Past the 50 MB cap: filler, never parsed. */
42 const HUGE = new Uint8Array(51 * MB).fill(0x3b);
43 HUGE.set(enc("G1 X1 Y1 E1\n"), 0);
44
45 describe("a G-code file on the page", () => {
46 let dataDir = "";
47 let app: App;
48 let servers: Servers;
49 let base = "";
50 let repo: Repo;
51 let cookie = "";
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-gcode-page-"));
55 app = createApp({ dataDir, port: 0 });
56 servers = await serve(app);
57 base = servers.url;
58 // The page asks as a stranger: lift the code door's per-minute allowance, which is not what this tests.
59 // And the main thread is not "behind" because another file in this process just filled 50 MB.
60 setCrawlClock(app, Date.now, false);
61 setStrangerLimits(app, { perAddress: { ...strangerLimits(app).perAddress, code: 1000 } });
62 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-gcode-passphrase" });
63 if (!a.ok) throw new Error("signup failed");
64 cookie = `forge_session=${createSession(app.db, a.value.id).token}`;
65 const created = await createRepo(app, a.value, { name: "prints", visibility: "public" });
66 if (!created.ok) throw new Error("repo failed");
67 repo = created.value;
68 const dir = repoDir(app.reposRoot, "alice", "prints");
69 const made = await commitToBranch(app, a.value, repo, { message: "start", files: [{ path: "README.md", content: "# prints\n" }] });
70 if (!made.ok) throw new Error("commit failed");
71 const git = (args: string[], stdin?: Uint8Array | string): string => {
72 const run = Bun.spawnSync(["git", ...args], {
73 cwd: dir,
74 stdin: typeof stdin === "string" ? enc(stdin) : (stdin ?? "ignore"),
75 env: { ...process.env, GIT_AUTHOR_NAME: "alice", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "alice", GIT_COMMITTER_EMAIL: "[email protected]" },
76 });
77 if (run.exitCode !== 0) throw new Error(`git ${args[0]}: ${run.stderr.toString()}`);
78 return run.stdout.toString().trim();
79 };
80 const files: Record<string, Uint8Array> = { "small.gcode": SMALL, "mid.gcode": MID, "huge.gcode": HUGE };
81 const lines = [git(["ls-tree", "main"])];
82 for (const [name, body] of Object.entries(files)) lines.push(`100644 blob ${git(["hash-object", "-w", "--stdin"], body)}\t${name}`);
83 const tree = git(["mktree"], lines.join("\n") + "\n");
84 git(["update-ref", "refs/heads/main", git(["commit-tree", tree, "-p", "main", "-m", "prints"])]);
85 }, 120_000);
86
87 afterAll(async () => {
88 await servers?.stop();
89 app?.close();
90 await rm(dataDir, { recursive: true, force: true });
91 });
92
93 /** As alice by default; `stranger` as an anonymous reader, whose pages are kept and whose budget is small. */
94 const page = async (path: string, query = "", stranger = false): Promise<string> => {
95 const res = await fetch(`${base}/alice/prints/blob/main/${path}${query}`, stranger ? {} : { headers: { cookie } });
96 const text = await res.text();
97 expect(res.status === 200 ? "ok" : text.slice(0, 200)).toBe("ok");
98 return text;
99 };
100
101 test("?layer= picks the layer; a stranger's non-number shows layer 1", async () => {
102 expect(await page("small.gcode", "?layer=2")).toContain('value="2"');
103 for (const bad of ["abc", "-3", "2.5", "0x2", "1e1", "99999999999", ""])
104 expect(await page("small.gcode", `?layer=${encodeURIComponent(bad)}`)).toContain('value="1"');
105 });
106
107 test("?layer= past the last layer shows the last, and does not spoil the page kept for the real number", async () => {
108 expect(await page("small.gcode", "?layer=500", true)).toContain('value="3"');
109 expect(await page("small.gcode", "?layer=500", true)).toContain('value="3"');
110 const real = await page("small.gcode", "?layer=2", true);
111 expect(real).toContain('value="2"');
112 expect(real).toContain("Layer 2");
113 expect(await page("small.gcode", "?layer=0", true)).toContain('value="1"');
114 });
115
116 test("over the 50 MB cap: no drawing, the kind and size, Download", async () => {
117 const text = await page("huge.gcode");
118 expect(text).toContain("G-code, over the 47.7 MB the page draws");
119 expect(text).toContain("48.6 MB");
120 expect(text).toContain("Download");
121 expect(text).not.toContain("gcode-view");
122 expect(text).not.toContain('name="layer"');
123 });
124
125 test("the local source reads a G-code file past 5 MB, up to the cap, and no further", async () => {
126 const source = localSource(app, repo);
127 const mid = await source.blob("main", "mid.gcode");
128 expect(mid.ok && mid.value.kind !== "too_large" && mid.value.size).toBe(MID.length);
129 expect(MID.length).toBeGreaterThan(5 * MB);
130 const huge = await source.blob("main", "huge.gcode");
131 expect(huge.ok && huge.value.kind).toBe("too_large");
132 });
133
134 test("a big file is parsed on a worker and its layers are drawn", async () => {
135 expect(MID.length).toBeGreaterThan(INLINE_MAX);
136 const text = await page("mid.gcode", "?layer=4");
137 expect(text).toContain("gcode-view");
138 expect(text).toContain('value="4"');
139 expect(keptGcode(MID)?.layers.length).toBe(4);
140 expect(layerCount(MID)).toBe(4);
141 }, 60_000);
142
143 test("a big body not yet parsed is never parsed on the calling thread", () => {
144 const fresh = enc(sliced(2, 20_000) + "; fresh\n");
145 expect(fresh.length).toBeGreaterThan(INLINE_MAX);
146 expect(layerCount(fresh)).toBe(0);
147 expect(keptGcode(fresh)).toBeUndefined();
148 });
149 });
150
151 describe("the parser's memory", () => {
152 test("a layer of many travels then an extrusion joins without spreading arguments", () => {
153 const lines = ["G1 Z0.2", "G1 X1 Y1 E1"];
154 for (let i = 0; i < 150_000; i++) lines.push(`G0 X${i % 50} Y${(i + 1) % 50}`);
155 lines.push("G1 X60 Y60 E2");
156 const g = parseGcode(lines.join("\n"))!;
157 expect(g.layers.length).toBe(1);
158 // The travel run is bounded to SEGMENT_CAP-ish moves, then the extruding two.
159 expect(g.layers[0]!.moves.length / 5).toBeLessThanOrEqual(20_000 * 2 + 3);
160 expect(g.layers[0]!.moves.length / 5).toBeGreaterThan(20_000);
161 });
162 });
test/gcode.test.ts added +140
@@ -0,0 +1,140 @@
1 /** Sliced G-code drawn one layer at a time: parsed once, drawn as numbers only. */
2 import { describe, expect, test } from "bun:test";
3 import { clock, gcode, layerSvg, parseGcode, parsedGcode, SEGMENT_CAP } from "../src/web/render/gcode.ts";
4 import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts";
5 import { RENDERERS } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8
9 /** A 20 × 20 square, three layers (Z 0.2, 0.4, 0.6), 1 mm of filament per 20 mm side. */
10 const square = (relative: boolean): string => {
11 const out = ["G21", "G90", relative ? "M83" : "M82", "G92 E0", ";TIME:754"];
12 let e = 0;
13 const side = (x: number, y: number): void => {
14 e += 1;
15 out.push(`G1 X${x} Y${y} E${relative ? 1 : e} F1200`);
16 };
17 for (const z of [0.2, 0.4, 0.6]) {
18 out.push(`G0 X0 Y0 Z${z} F6000`);
19 side(20, 0);
20 side(20, 20);
21 side(0, 20);
22 side(0, 0);
23 // a retract and its unretract add no filament
24 out.push(`G1 E${relative ? -0.8 : e - 0.8}`);
25 out.push(`G1 E${relative ? 0.8 : e}`);
26 }
27 return out.join("\n");
28 };
29
30 describe("gcode", () => {
31 test("registered: a text kind with a 50 MB cap", () => {
32 expect(fileKind("a.gcode", enc("G28\n"))).toBe("gcode");
33 expect(RENDER_CAP.gcode).toBe(50_000_000);
34 expect(RENDERERS.gcode).toBe(gcode);
35 });
36
37 test("a 3-layer square: 3 layers, a 20 × 20 box, 12 mm of filament, the stated time", () => {
38 const g = parseGcode(square(false))!;
39 expect(g.layers.length).toBe(3);
40 expect(g.max[0] - g.min[0]).toBe(20);
41 expect(g.max[1] - g.min[1]).toBe(20);
42 expect(g.max[2]).toBeCloseTo(0.6);
43 expect(g.filament).toBeCloseTo(12);
44 expect(g.seconds).toBe(754);
45 expect(g.layers[0]!.z).toBeCloseTo(0.2);
46 });
47
48 test("relative and absolute extrusion agree", () => {
49 const a = parseGcode(square(false))!;
50 const r = parseGcode(square(true))!;
51 expect(r.filament).toBeCloseTo(a.filament);
52 expect(r.layers.length).toBe(a.layers.length);
53 expect(r.max).toEqual(a.max);
54 });
55
56 test("G91 moves are relative, G92 resets the extruder", () => {
57 const g = parseGcode("G91\nG1 X5 Y0 E1\nG1 X0 Y5 E1\nG90\nG92 E0\nG1 X0 Y0 E2\n")!;
58 expect(g.filament).toBeCloseTo(3); // E stays absolute: 1 + 0 + (2 after G92 E0)
59 expect(g.max[0]).toBe(5);
60 expect(g.layers.length).toBe(1);
61 });
62
63 test("slicer time comments in two spellings", () => {
64 const body = "G1 X1 E1\n";
65 expect(parseGcode(`; estimated printing time (normal mode) = 1h 2m 3s\n${body}`)!.seconds).toBe(3723);
66 expect(parseGcode(`;TIME:60\n${body}`)!.seconds).toBe(60);
67 expect(parseGcode(body)!.seconds).toBeUndefined();
68 expect(clock(3723)).toBe("1 h 02 min");
69 });
70
71 test("a file with no extruding move is not drawn", () => {
72 expect(parseGcode("G28\nG1 X10 Y10\n")).toBeUndefined();
73 const r = gcode({ path: "a.gcode", size: 4, body: enc("G28\n"), media: "/m", download: "/d" });
74 expect(r.fallback).toBeDefined();
75 });
76
77 test("the page: one layer, previous and next links, a GET form, Download", () => {
78 const body = enc(square(false));
79 const page = (layer?: number) =>
80 String(gcode({ path: "a.gcode", size: body.length, body, media: "/m", download: "/d", pathHref: "/o/r/blob/main/a.gcode", ...(layer !== undefined ? { layer } : {}) }).content);
81 const two = page(2);
82 expect(two).toContain('href="/o/r/blob/main/a.gcode?layer=1"');
83 expect(two).toContain('href="/o/r/blob/main/a.gcode?layer=3"');
84 expect(two).toContain('<form method="get" action="/o/r/blob/main/a.gcode"');
85 expect(two).toContain('name="layer"');
86 expect(two).toContain('href="/d"');
87 expect(two).toContain("<svg");
88 expect(two).toContain("Estimated time");
89 expect(page(1)).not.toContain("layer=0");
90 expect(page(99)).toContain('value="3"');
91 expect(page(0)).toContain('value="1"');
92 expect(page(3)).not.toContain("layer=4");
93 });
94
95 test("the svg carries nothing from the file but numbers", () => {
96 const hostile = `;<script>alert(1)</script>"onload=x\nG1 X1 Y1 E1\n; </svg><script>\n` + square(false);
97 const g = parseGcode(hostile)!;
98 const { svg } = layerSvg(g, 0);
99 const text = svg.replace(/aria-label="[^"]*"/, "");
100 expect(text).not.toContain("script");
101 expect(text).not.toContain("onload");
102 // every d attribute is path commands and numbers only
103 for (const m of svg.matchAll(/ d="([^"]*)"/g)) expect(m[1]).toMatch(/^[ML0-9.\- ]*$/);
104 // the page, too: no byte of a comment reaches it
105 const page = String(gcode({ path: "a.gcode", size: 1, body: enc(hostile), media: "/m", download: "/d" }).content);
106 expect(page).not.toContain("alert");
107 });
108
109 test("a layer's drawing is bounded", () => {
110 let src = "";
111 for (let i = 1; i <= SEGMENT_CAP + 50; i++) src += `G1 X${i % 100 + 1} Y${(i * 7) % 100 + 1} E${i}\n`;
112 const g = parseGcode(src)!;
113 const { shown, total } = layerSvg(g, 0);
114 expect(total).toBe(SEGMENT_CAP + 50);
115 expect(shown).toBe(SEGMENT_CAP);
116 });
117
118 test("a body is parsed once", () => {
119 const body = enc(square(false));
120 expect(parsedGcode(body)).toBe(parsedGcode(new Uint8Array(body)));
121 });
122
123 test("a 10 MB file parses in well under a second", () => {
124 const lines: string[] = [];
125 let e = 0;
126 for (let i = 0; i < 280_000; i++) lines.push(`G1 X${(i % 200) + 0.123} Y${((i * 3) % 200) + 0.456} E${(e += 0.0123).toFixed(5)} F1800`);
127 // 100 layers
128 const per = 2800;
129 const src = lines.map((l, i) => (i % per === 0 ? `G1 Z${(i / per) * 0.2 + 0.2}\n${l}` : l)).join("\n");
130 expect(src.length).toBeGreaterThan(10_000_000);
131 const t = performance.now();
132 const g = parsedGcode(enc(src))!;
133 // Parsing is off the request thread for a body this big (test/gcode-page.test.ts); this bounds the work itself.
134 expect(performance.now() - t).toBeLessThan(3000);
135 expect(g.layers.length).toBe(100);
136 const r = performance.now();
137 gcode({ path: "a.gcode", size: src.length, body: enc(src), media: "/m", download: "/d", layer: 50 });
138 expect(performance.now() - r).toBeLessThan(500);
139 });
140 });
test/render-kinds.test.ts +2
@@ -76,6 +76,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
7676 vcard: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"],
7777 geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"],
7878 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
79 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
80 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
7981 };
8082
8183 describe("the registry of file kinds", () => {
test/tui-workspace.test.ts +22
@@ -482,6 +482,28 @@ describe("the terminal workspace", () => {
482482 }
483483 });
484484
485 test("a .gcode opens as its facts, sharing the web parser, and s flips to the source", async () => {
486 const made = await createRepo(forge, alice, { name: "prints3d", visibility: "public", description: "slices" });
487 if (!made.ok) throw new Error("repo failed");
488 await commitToBranch(forge, alice, made.value, {
489 message: "seed",
490 files: [{ path: "cube.gcode", content: "G21\nG90\nM82\nG92 E0\nG1 Z0.2\nG1 X10 Y0 E1\nG1 X10 Y10 E2\nG1 Z0.4\nG1 X0 Y10 E3\n" }],
491 });
492 const h = boot();
493 try {
494 await h.settle();
495 await open(h, "prints3d");
496 await openFile(h, "cube.gcode");
497 expect(h.text()).toContain("Layers");
498 expect(h.text()).toContain("Bounding box");
499 expect(h.text()).toContain("3 mm");
500 await h.press("s");
501 expect(h.text()).toContain("G1 X10 Y0 E1");
502 } finally {
503 h.stop();
504 }
505 });
506
485507 test("a .geojson opens as its features table, sharing the web parser, and s flips to the source", async () => {
486508 const made = await createRepo(forge, alice, { name: "places", visibility: "public", description: "geo" });
487509 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs