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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs/VISUAL_FEATURE_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ What moved to **covered** in the second public-product pass:
`progressivePreview` selector, **By speed** + **By object** color modes, the parametric fixture,
and nav links to the model / model-viewer / 2D / CNC-validation / headless-still pages.

**Animations wired up** (WebM, via the harness `encodeWebm` → `image2pipe`/mjpeg → VP8; embedded as
`<video autoplay loop muted playsinline>`): `scrub-sweep.webm` (segment scrub drawing the toolpath)
and `layer-buildup.webm` (layer-range sweep — the progressive reveal), in the feature gallery and the
workers concept page.

Still on the **to-capture** list (honest remainder — recorded, not forgotten):

- `needs animation`: segment/time **scrub**, **progressive / held reveal**, interaction-aware
quality, user-scrub-wins — temporal, so a GIF/short clip beats a still (harness has the ffmpeg
path via `lib/browser.mjs`; not yet wired into shots).
- `needs animation`: **time scrub**, interaction-aware quality, user-scrub-wins, and the *actual*
load-time progressive/`hold` reveal (the layer-buildup clip stands in for it today).
- `needs comparison`: **object-aware framing** (whole-job vs model-aware), **fresh vs stale**
progress, **render scope** (plate/object subset before/after).
- `needs screenshot`: **diagnostics panel** in-context, **transparent-background capture** over a
Expand All @@ -79,7 +83,7 @@ them up rather than rediscovering them.
| Capability | User-facing | Demo | Media | README | Pages gallery | Manual/pkg | Status |
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
| Layer-range clipping | ✅ | ✅ | `layer-clip-benchy` | ✅ | gallery | recipes | needs better coverage — show the range control + resulting cut |
| Segment scrub | ✅ | ✅ | — | — | gallery | recipes | needs animation — scrub is temporal |
| Segment scrub | ✅ | ✅ | `scrub-sweep.webm` | — | gallery | recipes | covered — scrub-sweep animation in the gallery |
| Time scrub + print-time estimate (slicer vs kinematic provenance) | ✅ | ✅ | — | mention | gallery | concept-progress-motion | needs animation + needs better coverage (provenance label) |
| Travel-move toggle | ✅ | ✅ | (in others) | ✅ | gallery | recipes | covered |
| Wipe-move toggle | ✅ | ✅ | — | mention | gallery | — | needs screenshot |
Expand Down Expand Up @@ -119,8 +123,8 @@ them up rather than rediscovering them.
| Line geometry | ✅ | ✅ | `render-lines` | mention | gallery | concept-workers | covered (tubes-vs-lines pair in gallery + concept) |
| Canvas 2D fallback (no WebGL/Three) | ✅ | 2d page | `canvas-2d-fallback` | ✅ | gallery | concept-workers | covered |
| Quality modes (full/adaptive/fast) | ✅ | partial | — | mention | gallery | concept-workers | needs demo exposure |
| Progressive preview (`auto`/`lines`) | ✅ | ✅ (selector) | — | ✅ | gallery | concept-workers | demo + docs covered; animation pending |
| Held / single clean reveal (`hold`) | ✅ | ✅ (selector) | — | ✅ | gallery | concept-workers | demo + docs covered; animation pending |
| Progressive preview (`auto`/`lines`) | ✅ | ✅ (selector) | `layer-buildup.webm` | ✅ | gallery | concept-workers | demo + docs + build-up animation; true load-time capture still pending |
| Held / single clean reveal (`hold`) | ✅ | ✅ (selector) | `layer-buildup.webm` | ✅ | gallery | concept-workers | demo + docs + build-up animation (stand-in); true load-time capture pending |
| Renderer build stages (`stage` event) | ✅ | ❌ | — | ✅ (prose) | gallery | concept-workers | needs animation |
| Interaction-aware quality | ✅ | ❌ | — | ✅ (prose) | gallery | concept-workers | needs animation |
| Disclosed decimation / degradation | ✅ | ✅ (disclosure) | — | ✅ (prose) | gallery | concept-workers | needs screenshot (disclosure text) |
Expand Down
2 changes: 2 additions & 0 deletions docs/manual/concept-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Input is **streamed** into the parser, and the renderer can append geometry as i
large print shows a growing preview (time-to-first-paint) rather than a spinner until the whole file
is parsed. The session reports progress as it goes.

<p><video autoplay loop muted playsinline width="640" poster="https://chestnutlabs.github.io/gcode-preview/media/viewer-benchy-tubes.png" style="max-width:100%;border-radius:8px;border:1px solid var(--color-accent)"><source src="https://chestnutlabs.github.io/gcode-preview/media/layer-buildup.webm" type="video/webm"></video></p>

The **`progressivePreview`** prop chooses how that stream is presented (3D):

- **`'lines'`** (default) — show the growing line preview as it parses, then swap in the final build.
Expand Down
10 changes: 10 additions & 0 deletions docs/manual/feature-gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ The core job: turn a sliced file into an interactive picture of the actual moves
| ![Calicat lower layers with retraction and de-retraction markers pinned on the path](../media/retraction-markers.png) | ![The calicat as flat single-pixel toolpath lines](../media/render-lines.png) |
| **Retraction & de-retraction markers.** Toggle markers for retractions, wipes, travel, and seams. | **Line geometry.** Flat one-pixel paths — lighter for very large files or low-GPU devices. |

<p><video autoplay loop muted playsinline width="720" poster="https://chestnutlabs.github.io/gcode-preview/media/viewer-benchy-tubes.png" style="max-width:100%;border-radius:8px;border:1px solid var(--gp-border)"><source src="https://chestnutlabs.github.io/gcode-preview/media/scrub-sweep.webm" type="video/webm"></video></p>

*Segment scrub sweeping the draw-range through 3DBenchy — the toolpath drawing itself in exact print
order. Scrubbing is a draw-range trim, so it stays smooth even on large models.*

Also here: **segment scrub** (step move-by-move), **time scrub** with an honest print-time estimate
(labeled *slicer estimate* vs *kinematic approximation*), and **source-line ↔ segment mapping** — click
a move to find its byte in the file, and back. See [recipes](recipes.md).
Expand Down Expand Up @@ -63,6 +68,11 @@ incoming telemetry; file-identity mismatches are detected and disclosed. See
| ![The calicat as lit 3D tubes](../media/render-tubes.png) | ![A single layer drawn by the Canvas 2D renderer, no WebGL](../media/canvas-2d-fallback.png) |
| **Tubes vs lines.** Automatic quality selection, or force either. | **Canvas 2D fallback.** A flat layer view with **no WebGL and no Three.js** — the 2D bundle never ships Three. |

<p><video autoplay loop muted playsinline width="720" poster="https://chestnutlabs.github.io/gcode-preview/media/viewer-benchy-tubes.png" style="max-width:100%;border-radius:8px;border:1px solid var(--gp-border)"><source src="https://chestnutlabs.github.io/gcode-preview/media/layer-buildup.webm" type="video/webm"></video></p>

*Raising the top layer builds the model bottom to top — the same draw-range mechanism, and the visual
of a progressive reveal.*

Underneath: **quality modes** (full / adaptive / fast), **interaction-aware quality** (drop detail
while orbiting, restore on settle), a **parallel geometry worker pool** (byte-identical tubes off the
main thread, degrading pool → serial → lines, all disclosed), **progressive preview** with a **single
Expand Down
Binary file added docs/media/layer-buildup.webm
Binary file not shown.
Binary file added docs/media/scrub-sweep.webm
Binary file not shown.
20 changes: 15 additions & 5 deletions tools/screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,18 @@ Keep framing, viewport, and filenames stable when adding shots so the set reads

## Animation (temporal captures)

For features where the interaction *is* the point (scrub, progressive reveal), the harness can
capture a frame sequence and encode it with the Playwright-bundled ffmpeg (auto-located; override
with `FFMPEG_PATH`). See `lib/browser.mjs` `resolveFfmpeg()`. Reach for animation only when a static
frame genuinely can't carry the feature — a single well-composed frame or a before/after pair is
usually clearer and cheaper.
For features where the interaction *is* the point (scrub, progressive reveal), a shot with
`"output": "webm"` and an `"anim"` spec sweeps a temporal control across frames and encodes a small,
looping, muted **WebM** — embedded on the docs site as `<video autoplay loop muted playsinline>`.

```json
{ "name": "scrub-sweep", "output": "webm",
"anim": { "kind": "scrub", "frames": 48, "fps": 16, "holdFrames": 10 } }
```

`kind` is `scrub` (segment draw-range) or `layers` (layer-range build-up); `holdFrames` repeats the
final frame so the loop pauses on the finished model. Encoding uses the Playwright-bundled ffmpeg
(auto-located; override with `FFMPEG_PATH`), which is a stripped `--disable-everything` build: it can
only **decode mjpeg/vp8** and **mux webm**, so frames are captured as **JPEG** and piped through
`image2pipe` → VP8 (no PNG decode, palette filters, or gif muxer exist — see `lib/browser.mjs`
`encodeWebm`). Reach for animation only when a static frame genuinely can't carry the feature.
34 changes: 33 additions & 1 deletion tools/screenshots/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { launchBrowser, readCanvasPng, savePngDataUrl } from './lib/browser.mjs';
import { launchBrowser, readCanvasPng, savePngDataUrl, encodeWebm } from './lib/browser.mjs';
import { docTheme, VIEW, SCALE, PAL, DOC_BACKGROUND_CSS } from './lib/presentation.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -188,6 +188,38 @@ async function driveToolpath(page, shot) {
);
await page.waitForTimeout(400);

// Animation: sweep a temporal control across frames and encode a looping WebM.
// The camera + theme set above stay fixed; only the draw-range changes. Frames
// are JPEG (the bundled ffmpeg can only decode mjpeg, not png — see encodeWebm).
if (shot.output === 'webm' && shot.anim) {
const spec = shot.anim;
const N = spec.frames || 40;
const hold = spec.holdFrames || 0; // repeat the final frame so the loop pauses full
const frames = [];
for (let i = 0; i <= N; i++) {
const t = i / N;
await page.evaluate(
({ kind, t }) => {
const r = window.viewer.renderer;
if (kind === 'scrub') {
const total = r.segmentCount;
r.setScrubPosition(t >= 1 ? null : Math.max(1, Math.round(t * total)));
} else if (kind === 'layers') {
const last = Math.max(0, r.layerCount - 1);
r.setLayerRange(0, Math.max(0, Math.round(t * last)));
}
r.render();
},
{ kind: spec.kind, t }
);
await page.waitForTimeout(spec.settleMs || 30);
frames.push(await readCanvasPng(page, 'view', true, 'image/jpeg', spec.quality || 0.9));
}
for (let h = 0; h < hold; h++) frames.push(frames[frames.length - 1]);
encodeWebm(frames, resolve(MEDIA, shot.name + '.webm'), { fps: spec.fps || 16, width: spec.width || 720 });
return;
}

const out = resolve(MEDIA, shot.name + '.png');
if (shot.fullPage) {
// page.screenshot stalls on a live WebGL surface under headless SwiftShader.
Expand Down
53 changes: 48 additions & 5 deletions tools/screenshots/lib/browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { chromium } from 'playwright-core';
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';

function playwrightCacheDir() {
if (process.platform === 'win32')
Expand Down Expand Up @@ -94,23 +95,65 @@ export async function launchBrowser() {
throw new Error('Could not launch any Chromium candidate:\n' + errors.join('\n'));
}

/** Copy a WebGL canvas' backing store into a 2D canvas and return a PNG data URL. */
export async function readCanvasPng(page, canvasId, renderFirst = true) {
/** Copy a WebGL canvas' backing store into a 2D canvas and return an image data URL. */
export async function readCanvasPng(page, canvasId, renderFirst = true, mime = 'image/png', quality = 0.92) {
return page.evaluate(
({ id, renderFirst }) => {
({ id, renderFirst, mime, quality }) => {
if (renderFirst && window.viewer?.renderer?.render) window.viewer.renderer.render();
const src = document.getElementById(id);
const c = document.createElement('canvas');
c.width = src.width;
c.height = src.height;
c.getContext('2d').drawImage(src, 0, 0);
return c.toDataURL('image/png');
return c.toDataURL(mime, quality);
},
{ id: canvasId, renderFirst }
{ id: canvasId, renderFirst, mime, quality }
);
}

/** Write a `data:image/png;base64,...` URL to disk. */
export function savePngDataUrl(outPath, dataUrl) {
writeFileSync(outPath, Buffer.from(dataUrl.split(',')[1], 'base64'));
}

const dataUrlToBuffer = (u) => Buffer.from(u.split(',')[1], 'base64');

/**
* Encode an array of JPEG frames (each a `data:image/jpeg` URL) into a looping,
* muted WebM (VP8) via the Playwright-bundled ffmpeg. That build is stripped
* (`--disable-everything`): it can only DECODE mjpeg/vp8 and MUX webm, so frames
* are piped as concatenated JPEGs through `image2pipe` + the mjpeg decoder — no
* PNG decode, palette filters, or gif muxer are available. Throws if ffmpeg is
* missing. WebM is embedded via `<video autoplay loop muted playsinline>`.
*/
export function encodeWebm(frameDataUrls, outPath, { fps = 16, width = 720 } = {}) {
const ffmpeg = resolveFfmpeg();
if (!ffmpeg) throw new Error('ffmpeg not found (set FFMPEG_PATH or install a Playwright browser bundle).');
const input = Buffer.concat(frameDataUrls.map(dataUrlToBuffer));
// -crf/-b:v 0 = constant-quality VP8; scale to an even width for the codec.
execFileSync(
ffmpeg,
[
'-y',
'-f',
'image2pipe',
'-c:v',
'mjpeg',
'-framerate',
String(fps),
'-i',
'pipe:0',
'-vf',
`scale=${width}:-2:flags=lanczos`,
'-c:v',
'libvpx',
'-b:v',
'0',
'-crf',
'22',
'-an',
outPath
],
{ input, stdio: ['pipe', 'ignore', 'inherit'], maxBuffer: 1 << 30 }
);
}
24 changes: 24 additions & 0 deletions tools/screenshots/shots.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,30 @@
"color": { "mode": "feature", "fallback": [0.55, 0.55, 0.55] },
"caption": "Line geometry: flat one-pixel paths — lighter, for very large files or low-GPU devices.",
"alt": "The calicat rendered as flat single-pixel toolpath lines"
},
{
"name": "scrub-sweep",
"page": "toolpath",
"class": "feature-proof",
"file": "gcodes/3DBenchy.gcode",
"quality": "tubes",
"color": { "mode": "feature", "fallback": [0.55, 0.55, 0.55] },
"output": "webm",
"anim": { "kind": "scrub", "frames": 48, "fps": 16, "holdFrames": 10 },
"caption": "Segment scrub: stepping the draw-range through the toolpath traces the exact print order — the animation IS the feature.",
"alt": "Animation of 3DBenchy drawing itself as the segment scrub sweeps from the first move to the last"
},
{
"name": "layer-buildup",
"page": "toolpath",
"class": "feature-proof",
"file": "gcodes/3DBenchy.gcode",
"quality": "tubes",
"color": { "mode": "feature", "fallback": [0.55, 0.55, 0.55] },
"output": "webm",
"anim": { "kind": "layers", "frames": 44, "fps": 16, "holdFrames": 10 },
"caption": "Layer-range sweep: raising the top layer builds the model up bottom to top — the progressive reveal, shown as motion.",
"alt": "Animation of 3DBenchy building up layer by layer from the base to the top as the layer range grows"
}
]
}
Loading