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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10893-a-sliced-gcode-file-shows-its-toolpath-o → main 2 commits fork 2 threads · 2 unresolved
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"; | ||
| 57 | 57 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 58 | 58 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| 59 | 59 | import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; |
| 60 | import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; | |
| 60 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 61 | 62 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 62 | 63 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; |
| @@ -375,6 +376,17 @@ function gpxTable(source: string, path: string): Table | null { | ||
| 375 | 376 | return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 }; |
| 376 | 377 | } |
| 377 | 378 | |
| 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 | ||
| 378 | 390 | /** `.geojson`: features, drawn as a table of their geometry type and properties. */ |
| 379 | 391 | function isGeoJsonPath(path: string): boolean { |
| 380 | 392 | return /\.geojson$/i.test(path); |
| @@ -553,6 +565,9 @@ class Tab { | ||
| 553 | 565 | } else if (isGpxPath(this.path)) { |
| 554 | 566 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 555 | 567 | 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); | |
| 556 | 571 | } else if (isGeoJsonPath(this.path)) { |
| 557 | 572 | this.table = geoJsonTable(new TextDecoder().decode(source)); |
| 558 | 573 | if (this.table !== null) this.grid = layoutTable(this.table); |
src/view/local.ts +4 −2
| @@ -43,7 +43,7 @@ import { | ||
| 43 | 43 | import { repoDir } from "../git/repos.ts"; |
| 44 | 44 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 45 | 45 | 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"; | |
| 47 | 47 | import type { |
| 48 | 48 | Blob, |
| 49 | 49 | Branch, |
| @@ -61,6 +61,8 @@ import type { | ||
| 61 | 61 | |
| 62 | 62 | /** The same ceiling the contents route uses; a page is a page. */ |
| 63 | 63 | 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); | |
| 64 | 66 | const COMMIT_PAGE = 50; |
| 65 | 67 | |
| 66 | 68 | export function localSource( |
| @@ -201,7 +203,7 @@ export function localSource( | ||
| 201 | 203 | |
| 202 | 204 | async blob(ref: string, path: string): Promise<Result<Blob, SourceError>> { |
| 203 | 205 | 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)); | |
| 205 | 207 | if (blob.kind === "missing") return err("not_found"); |
| 206 | 208 | if (blob.kind !== "too_large") return ok(blob); |
| 207 | 209 | // 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 @@ | ||
| 8 | 8 | import { availableParallelism } from "node:os"; |
| 9 | 9 | import type { Encoding } from "../http/compress.ts"; |
| 10 | 10 | import type { CodeRows } from "./code_rows.ts"; |
| 11 | import type { Gcode } from "./render/gcode.ts"; | |
| 11 | 12 | import type { Job } from "./code_rows_worker.ts"; |
| 12 | 13 | |
| 13 | 14 | /** The floor: one worker busy on a long file must not stall every cold page. */ |
| @@ -49,6 +50,8 @@ export function poolSize( | ||
| 49 | 50 | |
| 50 | 51 | const WORKERS = poolSize(); |
| 51 | 52 | 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; | |
| 52 | 55 | /** Jobs in flight past which the caller renders inline instead of queueing. */ |
| 53 | 56 | const MAX_PENDING = 64; |
| 54 | 57 | |
| @@ -86,7 +89,7 @@ function start(): Worker | undefined { | ||
| 86 | 89 | |
| 87 | 90 | type Spec = Job extends infer J ? (J extends Job ? Omit<J, "id"> : never) : never; |
| 88 | 91 | |
| 89 | function run<T>(job: Spec): Promise<T | undefined> { | |
| 92 | function run<T>(job: Spec, ms: number = JOB_MS): Promise<T | undefined> { | |
| 90 | 93 | if (waiting.size >= MAX_PENDING) return Promise.resolve(undefined); |
| 91 | 94 | if (pool.length < WORKERS) { |
| 92 | 95 | const w = start(); |
| @@ -99,7 +102,7 @@ function run<T>(job: Spec): Promise<T | undefined> { | ||
| 99 | 102 | const timer = setTimeout(() => { |
| 100 | 103 | waiting.delete(id); |
| 101 | 104 | resolve(undefined); |
| 102 | }, JOB_MS); | |
| 105 | }, ms); | |
| 103 | 106 | waiting.set(id, { |
| 104 | 107 | worker, |
| 105 | 108 | done: (result) => { |
| @@ -121,3 +124,7 @@ export const encodeOffThread = (body: Uint8Array<ArrayBuffer>, encoding: Encodin | ||
| 121 | 124 | |
| 122 | 125 | /** Test seam: the live workers. */ |
| 123 | 126 | 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 @@ | ||
| 1 | 1 | /** The worker side of `code_rows_pool.ts`: lay out rows, or encode a finished page. */ |
| 2 | 2 | import { compressBytes, type Encoding } from "../http/compress.ts"; |
| 3 | import { parseGcode } from "./render/gcode.ts"; | |
| 3 | 4 | import { codeRows } from "./code_rows.ts"; |
| 4 | 5 | |
| 5 | 6 | declare const self: Worker; |
| 6 | 7 | |
| 7 | 8 | export type Job = |
| 8 | 9 | | { id: number; kind: "rows"; body: Uint8Array; path: string; limit: number } |
| 10 | | { id: number; kind: "gcode"; body: Uint8Array } | |
| 9 | 11 | | { id: number; kind: "encode"; body: Uint8Array<ArrayBuffer>; encoding: Encoding }; |
| 10 | 12 | |
| 11 | 13 | self.onmessage = (e: MessageEvent<Job>) => { |
| @@ -14,6 +16,8 @@ self.onmessage = (e: MessageEvent<Job>) => { | ||
| 14 | 16 | if (job.kind === "rows") { |
| 15 | 17 | const text = new TextDecoder().decode(job.body).replace(/\n$/, ""); |
| 16 | 18 | 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 } }); | |
| 17 | 21 | } else { |
| 18 | 22 | postMessage({ id: job.id, result: compressBytes(job.body, job.encoding) }); |
| 19 | 23 | } |
src/web/pages/repo.ts +24 −3
| @@ -82,12 +82,13 @@ import { field, readForm } from "../form.ts"; | ||
| 82 | 82 | import { html, raw, when, type Html } from "../html.ts"; |
| 83 | 83 | import { creditLine } from "../credit_line.ts"; |
| 84 | 84 | 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"; | |
| 86 | 86 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 87 | 87 | import { renderMarkdown } from "../markdown.ts"; |
| 88 | 88 | import { overSize } from "../render/archive.ts"; |
| 89 | 89 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 90 | 90 | import { fontJs } from "../render/font.ts"; |
| 91 | import { INLINE_MAX, keepGcode, keptGcode, layerCount } from "../render/gcode.ts"; | |
| 91 | 92 | import { hasTable } from "../render/sqlite.ts"; |
| 92 | 93 | import { fileKind, HEAD_BYTES, kindByName, RENDER_CAP } from "../render/kinds.ts"; |
| 93 | 94 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| @@ -1100,6 +1101,8 @@ function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<ty | ||
| 1100 | 1101 | function overCap(path: string, blob: Blob): string | undefined { |
| 1101 | 1102 | if (kindByName(path) === "notebook" && blob.size > RENDER_CAP.notebook) |
| 1102 | 1103 | 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.`; | |
| 1103 | 1106 | if (blob.kind !== "binary" || fileKind(path, blob.body.subarray(0, HEAD_BYTES)) !== "archive") return undefined; |
| 1104 | 1107 | return blob.size > RENDER_CAP.archive ? overSize(RENDER_CAP.archive) : undefined; |
| 1105 | 1108 | } |
| @@ -1141,6 +1144,8 @@ export function renderBlob( | ||
| 1141 | 1144 | all?: boolean; |
| 1142 | 1145 | /** `?table=` on a SQLite file. */ |
| 1143 | 1146 | table?: string; |
| 1147 | /** `?layer=` on a G-code file. */ | |
| 1148 | layer?: number; | |
| 1144 | 1149 | /** Rows a worker already built for this file. */ |
| 1145 | 1150 | rows?: CodeRows; |
| 1146 | 1151 | }, |
| @@ -1161,6 +1166,7 @@ export function renderBlob( | ||
| 1161 | 1166 | pathHref: `${base}/blob/${encoded}/${o.path}`, |
| 1162 | 1167 | all: o.all === true, |
| 1163 | 1168 | ...(o.table !== undefined ? { table: o.table } : {}), |
| 1169 | ...(o.layer !== undefined ? { layer: o.layer } : {}), | |
| 1164 | 1170 | }) |
| 1165 | 1171 | : undefined; |
| 1166 | 1172 | const fellBack = drawn?.fallback; |
| @@ -1662,6 +1668,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1662 | 1668 | const view = blobView(ctx.url.searchParams.get("view")); |
| 1663 | 1669 | const all = ctx.url.searchParams.get("all") === "1"; |
| 1664 | 1670 | 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; | |
| 1665 | 1673 | // The whole page as it last went out to a stranger, encoded (`kept_page.ts`). |
| 1666 | 1674 | const keptKey = kept ? keptPageKey(ctx, repo.id) : undefined; |
| 1667 | 1675 | const finished = keptKey === undefined ? undefined : keptPage(keptKey); |
| @@ -1673,7 +1681,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1673 | 1681 | let keepable = true; |
| 1674 | 1682 | const keepFinished = (response: Response) => (keptKey === undefined || !keepable ? response : keepPage(keptKey, response, true)); |
| 1675 | 1683 | 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); | |
| 1677 | 1685 | if (hit !== undefined) { |
| 1678 | 1686 | git.done(); |
| 1679 | 1687 | return immutableWhenAddressed( |
| @@ -1701,6 +1709,16 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1701 | 1709 | const renderStart = performance.now(); |
| 1702 | 1710 | const shown = capped(blob.value, path); |
| 1703 | 1711 | 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); | |
| 1704 | 1722 | // A cold page for a stranger highlights and lays out its rows on a worker (#9630). |
| 1705 | 1723 | let rows: CodeRows | undefined; |
| 1706 | 1724 | if (kept && shown.kind === "text" && (view === "source" || !blobRenderer(scope, path, shown))) { |
| @@ -1718,6 +1736,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1718 | 1736 | highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms), |
| 1719 | 1737 | all, |
| 1720 | 1738 | ...(table !== undefined ? { table } : {}), |
| 1739 | ...(layer !== undefined ? { layer } : {}), | |
| 1721 | 1740 | }); |
| 1722 | 1741 | if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs); |
| 1723 | 1742 | const paneMs = performance.now() - renderStart - (highlightMs ?? 0); |
| @@ -1747,7 +1766,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1747 | 1766 | const objectSrc = drawsPdf(scope, path, shown, view); |
| 1748 | 1767 | const font = drawsFont(scope, path, shown, view); |
| 1749 | 1768 | 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, { | |
| 1751 | 1770 | title: `${filename} · ${repo.name}`, |
| 1752 | 1771 | explorer: explorerHtml, |
| 1753 | 1772 | content: pane, |
| @@ -1798,6 +1817,8 @@ export function capped(blob: Blob, path = ""): Blob { | ||
| 1798 | 1817 | // A SQLite file is read whole, up to its own cap, because the database is opened from the bytes. |
| 1799 | 1818 | if (blob.kind !== "too_large" && fileKind(path, blob.body.subarray(0, HEAD_BYTES)) === "sqlite" && blob.size <= RENDER_CAP.sqlite) |
| 1800 | 1819 | 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; | |
| 1801 | 1822 | return blob.kind !== "too_large" && blob.size > MAX_RENDER_BYTES |
| 1802 | 1823 | ? { kind: "too_large", size: blob.size, head: blob.body.slice(0, HEAD_BYTES) } |
| 1803 | 1824 | : 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 = | ||
| 33 | 33 | | "geojson" |
| 34 | 34 | | "calendar" |
| 35 | 35 | | "contacts" |
| 36 | | "gcode" | |
| 36 | 37 | | "text" |
| 37 | 38 | | "binary"; |
| 38 | 39 | |
| @@ -65,6 +66,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 65 | 66 | geojson: 5 * MB, |
| 66 | 67 | calendar: 5 * MB, |
| 67 | 68 | contacts: 5 * MB, |
| 69 | gcode: 50 * MB, | |
| 68 | 70 | text: 1 * MB, |
| 69 | 71 | binary: 0, |
| 70 | 72 | }; |
| @@ -178,6 +180,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 178 | 180 | ics: "calendar", |
| 179 | 181 | vcf: "contacts", |
| 180 | 182 | vcard: "contacts", |
| 183 | gcode: "gcode", | |
| 184 | gco: "gcode", | |
| 181 | 185 | }; |
| 182 | 186 | |
| 183 | 187 | /** |
src/web/render/renderers.ts +4
| @@ -12,6 +12,7 @@ import { config } from "./config.ts"; | ||
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { csv } from "./csv.ts"; |
| 14 | 14 | import { geojson } from "./geojson.ts"; |
| 15 | import { gcode } from "./gcode.ts"; | |
| 15 | 16 | import { gpx } from "./gpx.ts"; |
| 16 | 17 | import { font } from "./font.ts"; |
| 17 | 18 | import { ics } from "./ics.ts"; |
| @@ -47,6 +48,8 @@ export interface RenderInput { | ||
| 47 | 48 | readonly all?: boolean; |
| 48 | 49 | /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */ |
| 49 | 50 | 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; | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | export interface Rendered { |
| @@ -77,6 +80,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 77 | 80 | log, |
| 78 | 81 | subtitles, |
| 79 | 82 | gpx, |
| 83 | gcode, | |
| 80 | 84 | geojson, |
| 81 | 85 | calendar: ics, |
| 82 | 86 | contacts: vcf, |
src/web/style.ts +2
| @@ -27,6 +27,7 @@ import { boardCss } from "./pages/board_style.ts"; | ||
| 27 | 27 | import { base, layers, reset, utilities } from "./style/base.ts"; |
| 28 | 28 | import { components } from "./style/components.ts"; |
| 29 | 29 | import { content } from "./style/content.ts"; |
| 30 | import { gcodeCss } from "./render/gcode-css.ts"; | |
| 30 | 31 | import { model3dCss } from "./render/model3d-css.ts"; |
| 31 | 32 | import { pickCss } from "./pick.ts"; |
| 32 | 33 | import { studioCss } from "./studio/style.ts"; |
| @@ -41,6 +42,7 @@ export const css = [ | ||
| 41 | 42 | boardCss, |
| 42 | 43 | content, |
| 43 | 44 | model3dCss, |
| 45 | gcodeCss, | |
| 44 | 46 | utilities, |
| 45 | 47 | swatchCss, |
| 46 | 48 | 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]> = { | ||
| 76 | 76 | vcard: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"], |
| 77 | 77 | geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"], |
| 78 | 78 | 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"], | |
| 79 | 81 | }; |
| 80 | 82 | |
| 81 | 83 | describe("the registry of file kinds", () => { |
test/tui-workspace.test.ts +22
| @@ -482,6 +482,28 @@ describe("the terminal workspace", () => { | ||
| 482 | 482 | } |
| 483 | 483 | }); |
| 484 | 484 | |
| 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 | ||
| 485 | 507 | test("a .geojson opens as its features table, sharing the web parser, and s flips to the source", async () => { |
| 486 | 508 | const made = await createRepo(forge, alice, { name: "places", visibility: "public", description: "geo" }); |
| 487 | 509 | if (!made.ok) throw new Error("repo failed"); |
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.