@chestnutlabs/gcode-renderer-2d 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @chestnutlabs/gcode-renderer-2d
2
+
3
+ Low-resource **Canvas 2D layer renderer** for the [Chestnut Labs G-code Preview](https://github.com/ChestnutLabs/gcode-preview) toolpath stack (DD-014 / Epic E8).
4
+
5
+ An **opt-in** current/adjacent-layer 2D view over the existing `ToolpathIR`, for contexts where the
6
+ default Three.js renderer is a poor fit: old / low-end mobile, embedded printer touchscreens
7
+ (Fluidd/Mainsail-class UIs), and locked-down or WebGL-blocked browsers. One parse, one IR, a second
8
+ lightweight view — **no second parser path, no `three`, no framework**. Per-segment color is
9
+ single-sourced with the 3D renderer via `@chestnutlabs/gcode-colors`, so the two never disagree.
10
+
11
+ The memory ceiling is the point: drawing is bounded to the active (± adjacent, later phases) layer,
12
+ never the whole model.
13
+
14
+ > **Status:** E8 phases 1–3 done. Phase 1 (#212): the Canvas 2D core — draw one layer from the IR,
15
+ > honest color, fit-to-canvas. Phase 2 (#213): current-layer **+ adjacent "ghost" layers** (dimmed
16
+ > preceding layers, `adjacentLayers` default 1 / floor 0) over one stable model frame. Phase 3 (#214):
17
+ > selectable from the viewer via **`<GcodePreview renderer="2d" />`** (the 3D renderer is loaded on
18
+ > demand, so a 2D-only bundle never ships Three.js), plus a **live-progress "completed cut"**
19
+ > (`setProgress`). Still ahead: the low-resource benchmark + capability-honesty UX (#215).
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { LayerView2D } from '@chestnutlabs/gcode-renderer-2d';
25
+
26
+ const view = new LayerView2D(canvas, { colorMode: { mode: 'feature', palette, fallback: [0.7, 0.7, 0.7] } });
27
+ view.setToolpath(ir); // a ToolpathIR from the worker parser
28
+ view.setLayer(12);
29
+ view.render(); // clears + redraws the active layer
30
+ ```
31
+
32
+ The drawing core is also exposed as pure functions — `drawLayer(ctx, ir, opts)`, `computeLayerFit`,
33
+ `layerBounds2D`, `rgbToCss` — which take a minimal Canvas-2D-context interface and are fully
34
+ testable in Node with a recording mock.
35
+
36
+ ## Honesty
37
+
38
+ A flat per-layer 2D view cannot represent non-XY/CNC toolpaths or 3D-only options. A missing or
39
+ out-of-range layer draws nothing and reports `drawn: false` rather than fabricating geometry.
40
+ Color modes that read an optional IR channel (`feature`, `object`, `feedrate`) must be
41
+ capability-gated by the caller. The full capability-honesty UX for non-XY / `layers: unavailable`
42
+ inputs arrives with the phase-4 work (#215).
43
+
44
+ ## License
45
+
46
+ MIT © Chestnut Labs. See the [repository](https://github.com/ChestnutLabs/gcode-preview) for provenance and the full support policy.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @chestnutlabs/gcode-renderer-2d — low-resource Canvas 2D layer renderer (DD-014 / E8).
3
+ *
4
+ * An opt-in current/adjacent-layer 2D view over the existing `ToolpathIR`, for low-GPU,
5
+ * low-memory, or WebGL-blocked devices (old mobile, embedded printer UIs, locked-down browsers).
6
+ * One parse, one IR, a second lightweight view — no second parser path, no `three`, no framework.
7
+ * Per-segment color is single-sourced with the 3D renderer via `@chestnutlabs/gcode-colors`.
8
+ *
9
+ * Re-exports the color model so 2D consumers get `ColorMode`/`RGB` from one import.
10
+ */
11
+ export { LayerView2D, drawLayer, drawLayers, computeLayerFit, layerBounds2D, modelBounds2D, describe2DDisclosures, rgbToCss } from './layer-view.js';
12
+ export type { LayerView2DOptions, DrawLayerOptions, DrawLayerResult, DrawLayersOptions, DrawLayersResult, LayerProgress, LayerFit, LayerBounds2D, TravelStyle, CanvasContext2DLike } from './layer-view.js';
13
+ export type { ColorMode, RGB } from '@chestnutlabs/gcode-colors';
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EACL,WAAW,EACX,SAAS,EACT,UAAU,EACV,eAAe,EACf,aAAa,EACb,aAAa,EACb,qBAAqB,EACrB,QAAQ,EACT,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,QAAQ,EACR,aAAa,EACb,WAAW,EACX,mBAAmB,EACpB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @chestnutlabs/gcode-renderer-2d — low-resource Canvas 2D layer renderer (DD-014 / E8).
3
+ *
4
+ * An opt-in current/adjacent-layer 2D view over the existing `ToolpathIR`, for low-GPU,
5
+ * low-memory, or WebGL-blocked devices (old mobile, embedded printer UIs, locked-down browsers).
6
+ * One parse, one IR, a second lightweight view — no second parser path, no `three`, no framework.
7
+ * Per-segment color is single-sourced with the 3D renderer via `@chestnutlabs/gcode-colors`.
8
+ *
9
+ * Re-exports the color model so 2D consumers get `ColorMode`/`RGB` from one import.
10
+ */
11
+ export { LayerView2D, drawLayer, drawLayers, computeLayerFit, layerBounds2D, modelBounds2D, describe2DDisclosures, rgbToCss } from './layer-view.js';
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Low-resource Canvas 2D layer view (DD-014 / E8, phase 1, #212).
3
+ *
4
+ * Draws a single layer of a `ToolpathIR` as flat, top-down polylines to a Canvas 2D
5
+ * surface — the memory ceiling is one layer's segments, not the whole model (DD-014 §6/§7).
6
+ * Geometry is read straight from the SoA in the IR's origin-relative frame; per-segment
7
+ * color comes from the shared, renderer-agnostic `@chestnutlabs/gcode-colors` so 2D and 3D
8
+ * never disagree. No `three`, no WebGL.
9
+ *
10
+ * The drawing core ({@link drawLayer}, {@link computeLayerFit}) is pure and takes a minimal
11
+ * {@link CanvasContext2DLike}, so it is fully testable in Node with a recording mock; the
12
+ * {@link LayerView2D} class is a thin lifecycle wrapper over a real `<canvas>`.
13
+ */
14
+ import { type ToolpathIR } from '@chestnutlabs/toolpath-core';
15
+ import { type ColorMode, type RGB } from '@chestnutlabs/gcode-colors';
16
+ /** The subset of `CanvasRenderingContext2D` the layer view uses (a real context satisfies it). */
17
+ export type CanvasContext2DLike = Pick<CanvasRenderingContext2D, 'strokeStyle' | 'lineWidth' | 'lineCap' | 'lineJoin' | 'globalAlpha' | 'beginPath' | 'moveTo' | 'lineTo' | 'stroke' | 'clearRect'>;
18
+ /** XY extent of a set of segments, in the IR's origin-relative frame. */
19
+ export interface LayerBounds2D {
20
+ minX: number;
21
+ minY: number;
22
+ maxX: number;
23
+ maxY: number;
24
+ /** False when the layer has no segments (nothing to fit / draw). */
25
+ hasContent: boolean;
26
+ }
27
+ /** Maps printer XY (origin-relative) to canvas pixels, with Y flipped (printer up → canvas down). */
28
+ export interface LayerFit {
29
+ /** World-units-to-pixels scale. */
30
+ scale: number;
31
+ /** Project a printer XY point to device pixels `[px, py]`. */
32
+ project(x: number, y: number): [number, number];
33
+ }
34
+ export interface DrawLayerOptions {
35
+ /** Index into `ir.layers`. Out-of-range or absent → nothing drawn (honest no-op). */
36
+ layer: number;
37
+ /** Per-segment color mode (shared with the 3D renderer). Applied to extrusion moves. */
38
+ colorMode: ColorMode;
39
+ /** World→pixel mapping. Usually from {@link computeLayerFit} for the same layer. */
40
+ fit: LayerFit;
41
+ /** Extrusion stroke width in device px (default 1). */
42
+ lineWidth?: number;
43
+ /** Draw travel moves too, in this style. Omitted/false → extrusion only (the default). */
44
+ travel?: TravelStyle | false;
45
+ /** Layer opacity in [0,1] via `globalAlpha` (default 1). Used to dim adjacent "ghost" layers. */
46
+ opacity?: number;
47
+ /**
48
+ * Live-progress "completed cut" (DD-006): the last printed segment index in THIS layer. Extrusion
49
+ * segments at or before it draw at full opacity; those after it draw at {@link upcomingOpacity}
50
+ * (not yet printed). Omit for no progress split. Honest — nothing is hidden, only dimmed.
51
+ */
52
+ progressCutSeg?: number;
53
+ /** Opacity of not-yet-printed extrusion when {@link progressCutSeg} is set (default 0.15). */
54
+ upcomingOpacity?: number;
55
+ }
56
+ export interface TravelStyle {
57
+ /** CSS color for travel moves (e.g. a muted gray). */
58
+ color: string;
59
+ /** Travel stroke width in device px (default 0.5). */
60
+ lineWidth?: number;
61
+ }
62
+ export interface DrawLayerResult {
63
+ layer: number;
64
+ layerCount: number;
65
+ extrudeDrawn: number;
66
+ travelDrawn: number;
67
+ /** False when the layer index is out of range or the IR has no layer table. */
68
+ drawn: boolean;
69
+ }
70
+ /** Convert a float {@link RGB} triple to a CSS `rgb(...)` string for `strokeStyle`. */
71
+ export declare function rgbToCss(rgb: RGB): string;
72
+ /** XY bounds of one layer's segments (both endpoints), in the origin-relative frame. */
73
+ export declare function layerBounds2D(ir: ToolpathIR, layer: number): LayerBounds2D;
74
+ /**
75
+ * Fit `bounds` into a `width`×`height` canvas with uniform scale, centered, `padding` px inset,
76
+ * and the Y axis flipped so higher printer-Y draws higher on screen. Degenerate (zero-span or
77
+ * empty) bounds fall back to scale 1 centered at the canvas middle.
78
+ */
79
+ export declare function computeLayerFit(bounds: LayerBounds2D, width: number, height: number, padding?: number): LayerFit;
80
+ /**
81
+ * Draw one layer of `ir` to `ctx`. Extrusion moves are colored via {@link DrawLayerOptions.colorMode}
82
+ * (single-sourced with the 3D renderer); travel moves are drawn only when `travel` is given. The
83
+ * caller is responsible for clearing the canvas and for capability-gating the color mode. Returns an
84
+ * honest count; a missing/out-of-range layer draws nothing and reports `drawn: false`.
85
+ */
86
+ export declare function drawLayer(ctx: CanvasContext2DLike, ir: ToolpathIR, opts: DrawLayerOptions): DrawLayerResult;
87
+ /** XY bounds over ALL of the IR's segments — a stable frame for the layer view (not per-layer). */
88
+ export declare function modelBounds2D(ir: ToolpathIR): LayerBounds2D;
89
+ /**
90
+ * Honest capability disclosures for rendering `ir` as a flat 2D layer view (DD-014 §6/§11). Returns
91
+ * human-readable notes a consumer UI can show; empty when the IR is faithfully representable (planar
92
+ * FDM). It never suppresses geometry — it explains what the flat top-down projection cannot convey.
93
+ *
94
+ * - `layers: 'unavailable'` (non-planar / CNC / unindexed): the parser put every move on layer 0, so
95
+ * the 2D view shows them all in one flat frame — Z variation and non-XY (e.g. `G18`/`G19`) motion
96
+ * are not represented.
97
+ * - `layers: 'inferred'`: layer boundaries are approximate.
98
+ */
99
+ export declare function describe2DDisclosures(ir: ToolpathIR): string[];
100
+ export interface DrawLayersOptions {
101
+ /** Active (fully-drawn) layer index. */
102
+ layer: number;
103
+ /** Number of preceding layers drawn beneath the active one as dimmed "ghosts" (default 1, floor 0). */
104
+ adjacentLayers?: number;
105
+ /** Per-segment color mode for all drawn layers (shared with the 3D renderer). */
106
+ colorMode: ColorMode;
107
+ /** Shared world→pixel mapping — the SAME fit for every layer so they overlay in printer XY. */
108
+ fit: LayerFit;
109
+ /** Extrusion stroke width in device px (default 1). */
110
+ lineWidth?: number;
111
+ /** Draw travel moves too (default hidden). Applied to the active layer only. */
112
+ travel?: TravelStyle | false;
113
+ /** Opacity of each ghost layer in [0,1] (default 0.25). The active layer is always fully opaque. */
114
+ ghostOpacity?: number;
115
+ /** Live-progress cut (DD-006). Applied only when its `layerIndex` is the active layer. */
116
+ progress?: LayerProgress | null;
117
+ }
118
+ /** The minimal live-progress shape the 2D view needs (a projection of DD-006's `MappedProgress`). */
119
+ export interface LayerProgress {
120
+ /** Last printed segment index (global IR index); null when unknown. */
121
+ segIndex: number | null;
122
+ /** Layer currently printing; null when unknown. */
123
+ layerIndex: number | null;
124
+ }
125
+ export interface DrawLayersResult {
126
+ layer: number;
127
+ layerCount: number;
128
+ /** Ghost layer indices actually drawn (oldest→newest, beneath the active layer). */
129
+ ghostLayers: number[];
130
+ /** Extrusion segments drawn across the active layer + ghosts. */
131
+ extrudeDrawn: number;
132
+ travelDrawn: number;
133
+ drawn: boolean;
134
+ }
135
+ /**
136
+ * Draw the active layer over its preceding "ghost" layers (DD-014 D2, #213). Ghosts are the
137
+ * `adjacentLayers` layers immediately below the active one, drawn first (underneath) and dimmed to
138
+ * `ghostOpacity`; the active layer is drawn last at full opacity. All layers share ONE `fit`, so
139
+ * they overlay correctly in printer XY. `adjacentLayers: 0` draws the active layer only (the floor).
140
+ */
141
+ export declare function drawLayers(ctx: CanvasContext2DLike, ir: ToolpathIR, opts: DrawLayersOptions): DrawLayersResult;
142
+ export interface LayerView2DOptions {
143
+ /** Initial layer index (default 0). */
144
+ layer?: number;
145
+ /** Initial color mode (default single mid-gray). */
146
+ colorMode?: ColorMode;
147
+ /** Extrusion stroke width in device px (default 1). */
148
+ lineWidth?: number;
149
+ /** Travel style, or false to hide travel (default: hidden). */
150
+ travel?: TravelStyle | false;
151
+ /** Padding inset in device px (default 8). */
152
+ padding?: number;
153
+ /** Preceding "ghost" layers drawn beneath the active one (default 1, floor 0). */
154
+ adjacentLayers?: number;
155
+ /** Ghost-layer opacity in [0,1] (default 0.25). */
156
+ ghostOpacity?: number;
157
+ }
158
+ /**
159
+ * A thin lifecycle wrapper binding a `ToolpathIR` to a `<canvas>`: pick a layer, pick a color
160
+ * mode, `render()`. It owns no WebGL context and holds no per-layer geometry — each `render()`
161
+ * clears and redraws the active layer (plus its dimmed "ghost" layers), so peak memory stays near
162
+ * one layer (DD-014 §6). The view frame is the whole-model XY bounds (cached on `setToolpath`), so
163
+ * scrubbing layers doesn't jump or rescale and ghosts overlay the active layer exactly. Adapter
164
+ * wiring (the `renderer: '2d' | '3d'` prop) arrives in a later E8 phase.
165
+ */
166
+ export declare class LayerView2D {
167
+ private readonly canvas;
168
+ private ir;
169
+ private bounds;
170
+ private layer;
171
+ private colorMode;
172
+ private lineWidth;
173
+ private travel;
174
+ private padding;
175
+ private adjacentLayers;
176
+ private ghostOpacity;
177
+ private progress;
178
+ constructor(canvas: HTMLCanvasElement, opts?: LayerView2DOptions);
179
+ setToolpath(ir: ToolpathIR | null): void;
180
+ setLayer(layer: number): void;
181
+ setColorMode(colorMode: ColorMode): void;
182
+ /** Number of preceding ghost layers drawn beneath the active one (clamped to ≥ 0). */
183
+ setAdjacentLayers(n: number): void;
184
+ /** Live-progress cut (DD-006); null clears it. Applied only when on the active layer. */
185
+ setProgress(progress: LayerProgress | null): void;
186
+ /** Show/hide travel moves on the active layer (`false` hides). */
187
+ setTravel(travel: TravelStyle | false): void;
188
+ /**
189
+ * Honest capability notes for the current IR (DD-014 §6/§11) — e.g. a non-planar/CNC toolpath the
190
+ * flat 2D view cannot fully represent. Empty for faithfully-representable (planar) input.
191
+ */
192
+ getDisclosures(): string[];
193
+ /** Number of layers in the current IR (0 when none / no layer table). */
194
+ get layerCount(): number;
195
+ /** Clear and redraw the active layer + ghosts. Returns the draw result (drawn:false when empty). */
196
+ render(): DrawLayersResult;
197
+ }
198
+ //# sourceMappingURL=layer-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layer-view.d.ts","sourceRoot":"","sources":["../src/layer-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAY,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAwB,KAAK,SAAS,EAAE,KAAK,GAAG,EAAE,MAAM,4BAA4B,CAAC;AAE5F,kGAAkG;AAClG,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,wBAAwB,EACtB,aAAa,GACb,WAAW,GACX,SAAS,GACT,UAAU,GACV,aAAa,GACb,WAAW,GACX,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,WAAW,CACd,CAAC;AAEF,yEAAyE;AACzE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,qGAAqG;AACrG,MAAM,WAAW,QAAQ;IACvB,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,OAAO,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,SAAS,EAAE,SAAS,CAAC;IACrB,oFAAoF;IACpF,GAAG,EAAE,QAAQ,CAAC;IACd,uDAAuD;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IAC7B,iGAAiG;IACjG,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8FAA8F;IAC9F,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,KAAK,EAAE,OAAO,CAAC;CAChB;AAQD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAEzC;AAED,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,CAuB1E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,QAAQ,CAuB3G;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,mBAAmB,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,gBAAgB,GAAG,eAAe,CA6D3G;AAED,mGAAmG;AACnG,wBAAgB,aAAa,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,CAsB3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,EAAE,CAY9D;AAED,MAAM,WAAW,iBAAiB;IAChC,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,uGAAuG;IACvG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iFAAiF;IACjF,SAAS,EAAE,SAAS,CAAC;IACrB,+FAA+F;IAC/F,GAAG,EAAE,QAAQ,CAAC;IACd,uDAAuD;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IAC7B,oGAAoG;IACpG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;CACjC;AAED,qGAAqG;AACrG,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,mDAAmD;IACnD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,iEAAiE;IACjE,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,mBAAmB,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,iBAAiB,GAAG,gBAAgB,CAmD9G;AAED,MAAM,WAAW,kBAAkB;IACjC,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,uDAAuD;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IAC7B,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAID;;;;;;;GAOG;AACH,qBAAa,WAAW;IAapB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAZzB,OAAO,CAAC,EAAE,CAA2B;IACrC,OAAO,CAAC,MAAM,CAA4E;IAC1F,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,SAAS,CAAY;IAC7B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAA8B;gBAG3B,MAAM,EAAE,iBAAiB,EAC1C,IAAI,GAAE,kBAAuB;IAW/B,WAAW,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,IAAI;IAOxC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI7B,YAAY,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAIxC,sFAAsF;IACtF,iBAAiB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI;IAIlC,yFAAyF;IACzF,WAAW,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI;IAIjD,kEAAkE;IAClE,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,KAAK,GAAG,IAAI;IAI5C;;;OAGG;IACH,cAAc,IAAI,MAAM,EAAE;IAI1B,yEAAyE;IACzE,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,oGAAoG;IACpG,MAAM,IAAI,gBAAgB;CAyB3B"}
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Low-resource Canvas 2D layer view (DD-014 / E8, phase 1, #212).
3
+ *
4
+ * Draws a single layer of a `ToolpathIR` as flat, top-down polylines to a Canvas 2D
5
+ * surface — the memory ceiling is one layer's segments, not the whole model (DD-014 §6/§7).
6
+ * Geometry is read straight from the SoA in the IR's origin-relative frame; per-segment
7
+ * color comes from the shared, renderer-agnostic `@chestnutlabs/gcode-colors` so 2D and 3D
8
+ * never disagree. No `three`, no WebGL.
9
+ *
10
+ * The drawing core ({@link drawLayer}, {@link computeLayerFit}) is pure and takes a minimal
11
+ * {@link CanvasContext2DLike}, so it is fully testable in Node with a recording mock; the
12
+ * {@link LayerView2D} class is a thin lifecycle wrapper over a real `<canvas>`.
13
+ */
14
+ import { MoveKind } from '@chestnutlabs/toolpath-core';
15
+ import { createSegmentColorer } from '@chestnutlabs/gcode-colors';
16
+ /** Clamp a 0..1 float channel and scale to an 8-bit int. */
17
+ function chan(v) {
18
+ const c = v < 0 ? 0 : v > 1 ? 1 : v;
19
+ return Math.round(c * 255);
20
+ }
21
+ /** Convert a float {@link RGB} triple to a CSS `rgb(...)` string for `strokeStyle`. */
22
+ export function rgbToCss(rgb) {
23
+ return `rgb(${chan(rgb[0])}, ${chan(rgb[1])}, ${chan(rgb[2])})`;
24
+ }
25
+ /** XY bounds of one layer's segments (both endpoints), in the origin-relative frame. */
26
+ export function layerBounds2D(ir, layer) {
27
+ const empty = { minX: 0, minY: 0, maxX: 0, maxY: 0, hasContent: false };
28
+ const l = ir.layers[layer];
29
+ if (!l)
30
+ return empty;
31
+ const seg = ir.segments;
32
+ let minX = Infinity;
33
+ let minY = Infinity;
34
+ let maxX = -Infinity;
35
+ let maxY = -Infinity;
36
+ for (let i = l.segStart; i <= l.segEnd && i < seg.count; i++) {
37
+ const xs = [seg.x0[i], seg.x1[i]];
38
+ const ys = [seg.y0[i], seg.y1[i]];
39
+ for (const x of xs) {
40
+ if (x < minX)
41
+ minX = x;
42
+ if (x > maxX)
43
+ maxX = x;
44
+ }
45
+ for (const y of ys) {
46
+ if (y < minY)
47
+ minY = y;
48
+ if (y > maxY)
49
+ maxY = y;
50
+ }
51
+ }
52
+ if (minX > maxX)
53
+ return empty;
54
+ return { minX, minY, maxX, maxY, hasContent: true };
55
+ }
56
+ /**
57
+ * Fit `bounds` into a `width`×`height` canvas with uniform scale, centered, `padding` px inset,
58
+ * and the Y axis flipped so higher printer-Y draws higher on screen. Degenerate (zero-span or
59
+ * empty) bounds fall back to scale 1 centered at the canvas middle.
60
+ */
61
+ export function computeLayerFit(bounds, width, height, padding = 8) {
62
+ const availW = Math.max(1, width - 2 * padding);
63
+ const availH = Math.max(1, height - 2 * padding);
64
+ const spanX = bounds.maxX - bounds.minX;
65
+ const spanY = bounds.maxY - bounds.minY;
66
+ const scale = !bounds.hasContent || (spanX <= 0 && spanY <= 0)
67
+ ? 1
68
+ : Math.min(spanX > 0 ? availW / spanX : Infinity, spanY > 0 ? availH / spanY : Infinity);
69
+ const drawnW = spanX * scale;
70
+ const drawnH = spanY * scale;
71
+ const offsetX = padding + (availW - drawnW) / 2;
72
+ const offsetYTop = padding + (availH - drawnH) / 2;
73
+ const { minX, minY } = bounds;
74
+ return {
75
+ scale,
76
+ project(x, y) {
77
+ const px = offsetX + (x - minX) * scale;
78
+ // Flip Y: the top of the drawn box is offsetYTop; printer-Y grows upward.
79
+ const py = offsetYTop + (drawnH - (y - minY) * scale);
80
+ return [px, py];
81
+ }
82
+ };
83
+ }
84
+ /**
85
+ * Draw one layer of `ir` to `ctx`. Extrusion moves are colored via {@link DrawLayerOptions.colorMode}
86
+ * (single-sourced with the 3D renderer); travel moves are drawn only when `travel` is given. The
87
+ * caller is responsible for clearing the canvas and for capability-gating the color mode. Returns an
88
+ * honest count; a missing/out-of-range layer draws nothing and reports `drawn: false`.
89
+ */
90
+ export function drawLayer(ctx, ir, opts) {
91
+ const layerCount = ir.layers.length;
92
+ const l = ir.layers[opts.layer];
93
+ const base = {
94
+ layer: opts.layer,
95
+ layerCount,
96
+ extrudeDrawn: 0,
97
+ travelDrawn: 0,
98
+ drawn: false
99
+ };
100
+ if (!l)
101
+ return base;
102
+ const seg = ir.segments;
103
+ const colorOf = createSegmentColorer(ir, opts.colorMode);
104
+ const extrudeWidth = opts.lineWidth ?? 1;
105
+ const showTravel = opts.travel ? opts.travel : null;
106
+ ctx.lineCap = 'round';
107
+ ctx.lineJoin = 'round';
108
+ const prevAlpha = ctx.globalAlpha;
109
+ ctx.globalAlpha = opts.opacity ?? 1;
110
+ // Travel first (underneath), if requested.
111
+ let travelDrawn = 0;
112
+ if (showTravel) {
113
+ ctx.strokeStyle = showTravel.color;
114
+ ctx.lineWidth = showTravel.lineWidth ?? 0.5;
115
+ for (let i = l.segStart; i <= l.segEnd && i < seg.count; i++) {
116
+ if ((seg.kind[i] & MoveKind.Travel) === 0)
117
+ continue;
118
+ const [ax, ay] = opts.fit.project(seg.x0[i], seg.y0[i]);
119
+ const [bx, by] = opts.fit.project(seg.x1[i], seg.y1[i]);
120
+ ctx.beginPath();
121
+ ctx.moveTo(ax, ay);
122
+ ctx.lineTo(bx, by);
123
+ ctx.stroke();
124
+ travelDrawn++;
125
+ }
126
+ }
127
+ // Extrusion, colored per-segment. With a progress cut, segments after `progressCutSeg` in this
128
+ // layer are the not-yet-printed part — drawn dimmed (never hidden), the 2D "completed cut" (DD-006).
129
+ ctx.lineWidth = extrudeWidth;
130
+ const baseOpacity = opts.opacity ?? 1;
131
+ const upcomingOpacity = baseOpacity * (opts.upcomingOpacity ?? 0.15);
132
+ const cut = opts.progressCutSeg;
133
+ let extrudeDrawn = 0;
134
+ for (let i = l.segStart; i <= l.segEnd && i < seg.count; i++) {
135
+ if ((seg.kind[i] & MoveKind.Extrude) === 0)
136
+ continue;
137
+ ctx.globalAlpha = cut !== undefined && i > cut ? upcomingOpacity : baseOpacity;
138
+ ctx.strokeStyle = rgbToCss(colorOf(i));
139
+ const [ax, ay] = opts.fit.project(seg.x0[i], seg.y0[i]);
140
+ const [bx, by] = opts.fit.project(seg.x1[i], seg.y1[i]);
141
+ ctx.beginPath();
142
+ ctx.moveTo(ax, ay);
143
+ ctx.lineTo(bx, by);
144
+ ctx.stroke();
145
+ extrudeDrawn++;
146
+ }
147
+ ctx.globalAlpha = prevAlpha;
148
+ return { layer: opts.layer, layerCount, extrudeDrawn, travelDrawn, drawn: true };
149
+ }
150
+ /** XY bounds over ALL of the IR's segments — a stable frame for the layer view (not per-layer). */
151
+ export function modelBounds2D(ir) {
152
+ const seg = ir.segments;
153
+ let minX = Infinity;
154
+ let minY = Infinity;
155
+ let maxX = -Infinity;
156
+ let maxY = -Infinity;
157
+ for (let i = 0; i < seg.count; i++) {
158
+ const x0 = seg.x0[i];
159
+ const x1 = seg.x1[i];
160
+ const y0 = seg.y0[i];
161
+ const y1 = seg.y1[i];
162
+ if (x0 < minX)
163
+ minX = x0;
164
+ if (x1 < minX)
165
+ minX = x1;
166
+ if (x0 > maxX)
167
+ maxX = x0;
168
+ if (x1 > maxX)
169
+ maxX = x1;
170
+ if (y0 < minY)
171
+ minY = y0;
172
+ if (y1 < minY)
173
+ minY = y1;
174
+ if (y0 > maxY)
175
+ maxY = y0;
176
+ if (y1 > maxY)
177
+ maxY = y1;
178
+ }
179
+ if (minX > maxX)
180
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0, hasContent: false };
181
+ return { minX, minY, maxX, maxY, hasContent: true };
182
+ }
183
+ /**
184
+ * Honest capability disclosures for rendering `ir` as a flat 2D layer view (DD-014 §6/§11). Returns
185
+ * human-readable notes a consumer UI can show; empty when the IR is faithfully representable (planar
186
+ * FDM). It never suppresses geometry — it explains what the flat top-down projection cannot convey.
187
+ *
188
+ * - `layers: 'unavailable'` (non-planar / CNC / unindexed): the parser put every move on layer 0, so
189
+ * the 2D view shows them all in one flat frame — Z variation and non-XY (e.g. `G18`/`G19`) motion
190
+ * are not represented.
191
+ * - `layers: 'inferred'`: layer boundaries are approximate.
192
+ */
193
+ export function describe2DDisclosures(ir) {
194
+ const notes = [];
195
+ const layers = ir.header.capabilities['layers'];
196
+ if (layers === 'unavailable') {
197
+ notes.push('No planar layer index (non-planar or CNC toolpath): showing all moves in one flat top-down ' +
198
+ 'view. Z variation and non-XY (e.g. G18/G19 plane) motion are not represented in 2D.');
199
+ }
200
+ else if (layers === 'inferred') {
201
+ notes.push('Layer index is inferred; layer boundaries may be approximate.');
202
+ }
203
+ return notes;
204
+ }
205
+ /**
206
+ * Draw the active layer over its preceding "ghost" layers (DD-014 D2, #213). Ghosts are the
207
+ * `adjacentLayers` layers immediately below the active one, drawn first (underneath) and dimmed to
208
+ * `ghostOpacity`; the active layer is drawn last at full opacity. All layers share ONE `fit`, so
209
+ * they overlay correctly in printer XY. `adjacentLayers: 0` draws the active layer only (the floor).
210
+ */
211
+ export function drawLayers(ctx, ir, opts) {
212
+ const layerCount = ir.layers.length;
213
+ const base = {
214
+ layer: opts.layer,
215
+ layerCount,
216
+ ghostLayers: [],
217
+ extrudeDrawn: 0,
218
+ travelDrawn: 0,
219
+ drawn: false
220
+ };
221
+ if (!ir.layers[opts.layer])
222
+ return base;
223
+ const adjacent = Math.max(0, Math.floor(opts.adjacentLayers ?? 1));
224
+ const ghostOpacity = opts.ghostOpacity ?? 0.25;
225
+ const firstGhost = Math.max(0, opts.layer - adjacent);
226
+ const ghostLayers = [];
227
+ let extrudeDrawn = 0;
228
+ let travelDrawn = 0;
229
+ // Ghosts first (oldest→newest), dimmed, extrusion only — a depth cue, not the focus.
230
+ for (let li = firstGhost; li < opts.layer; li++) {
231
+ const r = drawLayer(ctx, ir, {
232
+ layer: li,
233
+ colorMode: opts.colorMode,
234
+ fit: opts.fit,
235
+ lineWidth: opts.lineWidth,
236
+ opacity: ghostOpacity
237
+ });
238
+ if (r.drawn) {
239
+ ghostLayers.push(li);
240
+ extrudeDrawn += r.extrudeDrawn;
241
+ }
242
+ }
243
+ // Active layer last, full opacity, with travel if requested. Apply the progress cut only when the
244
+ // live-progress layer IS the active layer — otherwise the marker would be misplaced (honest).
245
+ const p = opts.progress;
246
+ const progressCutSeg = p != null && p.segIndex != null && p.layerIndex === opts.layer ? p.segIndex : undefined;
247
+ const active = drawLayer(ctx, ir, {
248
+ layer: opts.layer,
249
+ colorMode: opts.colorMode,
250
+ fit: opts.fit,
251
+ lineWidth: opts.lineWidth,
252
+ travel: opts.travel,
253
+ progressCutSeg
254
+ });
255
+ extrudeDrawn += active.extrudeDrawn;
256
+ travelDrawn += active.travelDrawn;
257
+ return { layer: opts.layer, layerCount, ghostLayers, extrudeDrawn, travelDrawn, drawn: true };
258
+ }
259
+ const DEFAULT_COLOR_MODE = { mode: 'single', color: [0.55, 0.72, 0.55] };
260
+ /**
261
+ * A thin lifecycle wrapper binding a `ToolpathIR` to a `<canvas>`: pick a layer, pick a color
262
+ * mode, `render()`. It owns no WebGL context and holds no per-layer geometry — each `render()`
263
+ * clears and redraws the active layer (plus its dimmed "ghost" layers), so peak memory stays near
264
+ * one layer (DD-014 §6). The view frame is the whole-model XY bounds (cached on `setToolpath`), so
265
+ * scrubbing layers doesn't jump or rescale and ghosts overlay the active layer exactly. Adapter
266
+ * wiring (the `renderer: '2d' | '3d'` prop) arrives in a later E8 phase.
267
+ */
268
+ export class LayerView2D {
269
+ canvas;
270
+ ir = null;
271
+ bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0, hasContent: false };
272
+ layer;
273
+ colorMode;
274
+ lineWidth;
275
+ travel;
276
+ padding;
277
+ adjacentLayers;
278
+ ghostOpacity;
279
+ progress = null;
280
+ constructor(canvas, opts = {}) {
281
+ this.canvas = canvas;
282
+ this.layer = opts.layer ?? 0;
283
+ this.colorMode = opts.colorMode ?? DEFAULT_COLOR_MODE;
284
+ this.lineWidth = opts.lineWidth ?? 1;
285
+ this.travel = opts.travel ?? false;
286
+ this.padding = opts.padding ?? 8;
287
+ this.adjacentLayers = Math.max(0, Math.floor(opts.adjacentLayers ?? 1));
288
+ this.ghostOpacity = opts.ghostOpacity ?? 0.25;
289
+ }
290
+ setToolpath(ir) {
291
+ this.ir = ir;
292
+ // Cache the stable whole-model frame once (a scan is O(segments); render() stays layer-bounded).
293
+ this.bounds = ir ? modelBounds2D(ir) : { minX: 0, minY: 0, maxX: 0, maxY: 0, hasContent: false };
294
+ if (ir && this.layer > ir.layers.length - 1)
295
+ this.layer = Math.max(0, ir.layers.length - 1);
296
+ }
297
+ setLayer(layer) {
298
+ this.layer = layer < 0 ? 0 : layer;
299
+ }
300
+ setColorMode(colorMode) {
301
+ this.colorMode = colorMode;
302
+ }
303
+ /** Number of preceding ghost layers drawn beneath the active one (clamped to ≥ 0). */
304
+ setAdjacentLayers(n) {
305
+ this.adjacentLayers = Math.max(0, Math.floor(n));
306
+ }
307
+ /** Live-progress cut (DD-006); null clears it. Applied only when on the active layer. */
308
+ setProgress(progress) {
309
+ this.progress = progress;
310
+ }
311
+ /** Show/hide travel moves on the active layer (`false` hides). */
312
+ setTravel(travel) {
313
+ this.travel = travel;
314
+ }
315
+ /**
316
+ * Honest capability notes for the current IR (DD-014 §6/§11) — e.g. a non-planar/CNC toolpath the
317
+ * flat 2D view cannot fully represent. Empty for faithfully-representable (planar) input.
318
+ */
319
+ getDisclosures() {
320
+ return this.ir ? describe2DDisclosures(this.ir) : [];
321
+ }
322
+ /** Number of layers in the current IR (0 when none / no layer table). */
323
+ get layerCount() {
324
+ return this.ir?.layers.length ?? 0;
325
+ }
326
+ /** Clear and redraw the active layer + ghosts. Returns the draw result (drawn:false when empty). */
327
+ render() {
328
+ const ctx = this.canvas.getContext('2d');
329
+ const empty = {
330
+ layer: this.layer,
331
+ layerCount: this.layerCount,
332
+ ghostLayers: [],
333
+ extrudeDrawn: 0,
334
+ travelDrawn: 0,
335
+ drawn: false
336
+ };
337
+ if (!ctx)
338
+ return empty;
339
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
340
+ if (!this.ir)
341
+ return empty;
342
+ const fit = computeLayerFit(this.bounds, this.canvas.width, this.canvas.height, this.padding);
343
+ return drawLayers(ctx, this.ir, {
344
+ layer: this.layer,
345
+ adjacentLayers: this.adjacentLayers,
346
+ colorMode: this.colorMode,
347
+ fit,
348
+ lineWidth: this.lineWidth,
349
+ travel: this.travel,
350
+ ghostOpacity: this.ghostOpacity,
351
+ progress: this.progress
352
+ });
353
+ }
354
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@chestnutlabs/gcode-renderer-2d",
3
+ "version": "0.3.0",
4
+ "description": "Low-resource Canvas 2D layer renderer for the Chestnut Labs G-code viewer (DD-014 / E8): an opt-in current/adjacent-layer 2D view over the existing ToolpathIR for low-GPU/low-memory/WebGL-blocked devices. No three, no framework.",
5
+ "keywords": [
6
+ "gcode",
7
+ "3d-printing",
8
+ "toolpath",
9
+ "cnc",
10
+ "chestnutlabs",
11
+ "canvas",
12
+ "canvas2d",
13
+ "low-resource",
14
+ "layer-view",
15
+ "renderer"
16
+ ],
17
+ "author": "Chestnut Labs",
18
+ "homepage": "https://github.com/ChestnutLabs/gcode-preview#readme",
19
+ "bugs": "https://github.com/ChestnutLabs/gcode-preview/issues",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ChestnutLabs/gcode-preview.git",
23
+ "directory": "packages/gcode-renderer-2d"
24
+ },
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "test": "vitest run"
50
+ },
51
+ "dependencies": {
52
+ "@chestnutlabs/gcode-colors": "0.3.0",
53
+ "@chestnutlabs/toolpath-core": "0.3.0"
54
+ }
55
+ }