cli: snapshot draws a script's model to a PNG with no window

fieldcraft-cli snapshot script.json --out shot.png [--view] [--size WxH]
draws the engine's framed scene (engine::view::frame, shared with the CPU
render_png) with the viewport's GPU pipelines offscreen: hardware GPU, then
a software one, then the CPU rasterizer (FIELDCRAFT_GPU=auto|hardware|
software|cpu). CI installs lavapipe so the GPU path runs there.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 20:19:00 -04:00
1 parent 248e74ca9e
commit fee978c7e4
9 files changed
+259 -13

No files matched your search

+2
View File
@@ -132,6 +132,8 @@ jobs:
# Shared keys: release.yml's verify jobs restore these caches (they save only on main).
shared-key: ci
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Software GPU (lavapipe), so headless snapshots run the GPU pipeline
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends mesa-vulkan-drivers
- name: cargo xtask ci (the affected crates, or everything)
env:
BASE: ${{ needs.plan.outputs.base }}
Generated
+3
View File
@@ -1292,9 +1292,12 @@ name = "fieldcraft-cli"
version = "0.6.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-gpu",
"fieldcraft-io",
"fieldcraft-mcp",
"fieldcraft-render",
"fieldcraft-update",
"png",
"serde_json",
]
+1
View File
@@ -118,6 +118,7 @@ cargo xtask ci # fmt, assets, layers, clippy, t
cargo xtask test --heavy # the heavy (ignored) tests and the doc tests
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo run -p fieldcraft-cli -- run script.json # run a command script
cargo run -p fieldcraft-cli -- snapshot script.json --out shot.png # draw it to a PNG, no window
cargo run -p fieldcraft-cli -- mcp # MCP server on stdio for AI agents (docs/mcp.md)
```
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fieldcraft-cli"
description = "FieldCraft from the command line: list commands, run scripts, serve MCP"
description = "FieldCraft from the command line: list commands, run scripts, draw snapshots, serve MCP"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,10 +10,13 @@ workspace = true
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-gpu = { workspace = true }
fieldcraft-render = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-mcp = { workspace = true }
fieldcraft-update = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
png = { workspace = true }
fieldcraft-update = { workspace = true, features = ["mock"] }
+16 -1
View File
@@ -1,5 +1,6 @@
//! fieldcraft-cli commands
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view iso|x|y|z|front|…] [--size WxH]
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
@@ -9,6 +10,7 @@
#![forbid(unsafe_code)]
mod crashes;
mod snapshot;
mod update;
use std::path::Path;
@@ -23,6 +25,12 @@ usage:
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
and print the model summary
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V] [--size WxH]
run a script and draw the model to a PNG with no
window: the viewport's GPU pipeline on a hardware or
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
auto|hardware|software|cpu); V: iso (default), x, y, z,
front, back, left, right, top, bottom; size 1280x800
fieldcraft-cli features print docs/features.md (every capability, ready or
coming soon) from the capability catalogue
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
@@ -67,6 +75,7 @@ fn main() -> ExitCode {
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
Some("update") => update::run(args.get(1..).unwrap_or(&[])),
Some("snapshot") => snapshot::run(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
@@ -135,7 +144,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
}
fn run(path: &str) -> Result<(), String> {
/// The session after running the script at `path`.
fn session_of(path: &str) -> Result<Session, String> {
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > MAX_SCRIPT_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024)));
@@ -144,6 +154,11 @@ fn run(path: &str) -> Result<(), String> {
let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::default();
session.run_script(&script).map_err(|e| e.to_string())?;
Ok(session)
}
fn run(path: &str) -> Result<(), String> {
let mut session = session_of(path)?;
let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?);
Ok(())
+205
View File
@@ -0,0 +1,205 @@
//! `fieldcraft-cli snapshot`: a script in, a PNG out, with no window and no display. The picture
//! is the viewport's: the engine's scene ([`fieldcraft_engine::view::frame`]) drawn with the
//! window's GPU pipelines ([`fieldcraft_gpu::capture`]) on a hardware GPU or a software one
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
//! [`fieldcraft_gpu::select`]).
use fieldcraft_engine::view::{self, Palette};
use fieldcraft_gpu::capture::Capturer;
use fieldcraft_gpu::select::Policy;
use fieldcraft_render::{Orientation, StandardView};
/// The picture size when `--size` is not given.
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
#[derive(Debug, PartialEq)]
pub struct Options {
pub script: String,
pub out: String,
pub view: StandardView,
pub size: (u32, u32),
}
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
/// front, `z`: from the top).
fn view_of(s: &str) -> Option<StandardView> {
match s.to_ascii_lowercase().as_str() {
"x" => Some(StandardView::Right),
"y" => Some(StandardView::Front),
"z" => Some(StandardView::Top),
other => StandardView::parse(other),
}
}
/// `WxH`, each side 1..=the renderer's limit.
fn size_of(s: &str) -> Option<(u32, u32)> {
let (w, h) = s.split_once(['x', 'X'])?;
let (w, h) = (w.trim().parse::<u32>().ok()?, h.trim().parse::<u32>().ok()?);
let max = fieldcraft_gpu::capture::MAX_SIDE;
((1..=max).contains(&w) && (1..=max).contains(&h) && u64::from(w) * u64::from(h) <= fieldcraft_gpu::capture::MAX_PIXELS).then_some((w, h))
}
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
pub fn options(args: &[String]) -> Result<Options, String> {
let (mut script, mut out, mut view, mut size) = (None, None, None, None);
let mut it = args.iter();
while let Some(arg) = it.next() {
if !arg.starts_with("--") {
if script.replace(arg.clone()).is_some() {
return Err(format!("snapshot takes one script, not `{arg}` too"));
}
continue;
}
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) => (f, Some(v.to_string())),
None => (arg.as_str(), None),
};
let value = inline.or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value"))?;
let twice = match flag {
"--out" => out.replace(value).is_some(),
"--view" => view
.replace(
view_of(&value).ok_or_else(|| format!("--view: unknown view `{value}` (iso, x, y, z, front, back, left, right, top, bottom)"))?,
)
.is_some(),
"--size" => size
.replace(size_of(&value).ok_or_else(|| {
format!(
"--size: `{value}` is not WxH with each side 1..={} and at most {} pixels",
fieldcraft_gpu::capture::MAX_SIDE,
fieldcraft_gpu::capture::MAX_PIXELS
)
})?)
.is_some(),
other => return Err(format!("snapshot: unknown option `{other}`")),
};
if twice {
return Err(format!("{flag} is given twice"));
}
}
Ok(Options {
script: script.ok_or("snapshot needs a script")?,
out: out.ok_or("snapshot needs --out FILE.png")?,
view: view.unwrap_or(StandardView::Iso),
size: size.unwrap_or(DEFAULT_SIZE),
})
}
/// What drew a picture.
#[derive(Debug, PartialEq)]
pub enum Drawn {
Gpu { adapter: String, tier: &'static str, samples: u32 },
Cpu { why: Option<String> },
}
/// The PNG of the session `o` describes, drawn as `policy` says. Under [`Policy::Auto`] a GPU
/// that cannot be opened or fails to draw falls back to the CPU rasterizer (the reason is
/// returned); a strict policy returns the error.
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Drawn), String> {
let session = crate::session_of(&o.script)?;
let (w, h) = o.size;
let f = view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT);
let gpu = Capturer::open(policy).and_then(|c| match c {
Some(c) => c.rgba(&f.scene, &[&f.symbols], &f.cam, w, h).map(|px| Some((px, c))),
None => Ok(None),
});
let why = match gpu {
Ok(Some((px, c))) => {
let png = fieldcraft_render::png(w as usize, h as usize, &px).ok_or("the picture could not be encoded")?;
return Ok((png, Drawn::Gpu { adapter: c.adapter.clone(), tier: c.tier.label(), samples: c.samples() }));
}
Ok(None) => (policy == Policy::Auto).then(|| "no GPU adapter".to_string()),
Err(e) if policy == Policy::Auto => Some(e.to_string()),
Err(e) => return Err(e.to_string()),
};
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w as usize, h as usize).map_err(|e| format!("render: {e}"))?;
Ok((png, Drawn::Cpu { why }))
}
pub fn run(args: &[String]) -> Result<(), String> {
let o = options(args)?;
let policy = Policy::from_env().map_err(|e| e.to_string())?;
let (png, drawn) = picture(&o, policy)?;
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
let renderer = match &drawn {
Drawn::Gpu { adapter, tier, samples } => serde_json::json!({"kind": tier, "adapter": adapter, "msaa": samples}),
Drawn::Cpu { why } => serde_json::json!({"kind": "CPU rasterizer", "why": why}),
};
let report = serde_json::json!({"out": o.out, "width": o.size.0, "height": o.size.1, "view": o.view.label(), "renderer": renderer});
println!("{report}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(args: &[&str]) -> Result<Options, String> {
options(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn options_are_checked() {
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
assert_eq!(o, Options { script: "s.json".into(), out: "a.png".into(), view: StandardView::Iso, size: DEFAULT_SIZE });
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
assert_eq!(opts(&["s", "--out", "o", "--view", "front"]).unwrap().view, StandardView::Front);
for bad in [
vec![],
vec!["s.json"],
vec!["--out", "a.png"],
vec!["s", "t", "--out", "o"],
vec!["s", "--out"],
vec!["s", "--out", "o", "--out", "p"],
vec!["s", "--out", "o", "--view", "diagonal"],
vec!["s", "--out", "o", "--size", "0x10"],
vec!["s", "--out", "o", "--size", "10"],
vec!["s", "--out", "o", "--size", "99999x10"],
vec!["s", "--out", "o", "--size", "8192x8192"],
vec!["s", "--out", "o", "--size", "-5x10"],
vec!["s", "--out", "o", "--nope", "1"],
] {
assert!(opts(&bad).is_err(), "{bad:?}");
}
}
/// PNG width, height and pixels (RGBA).
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
/// machine has one and on the CPU otherwise (a CI box without a software GPU); `cpu` always
/// on the CPU, and both pictures show the same model.
#[test]
fn snapshots_the_model_and_its_mesh_headless() {
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("cantilever.json");
std::fs::write(&script, fieldcraft_engine::sample::cantilever().to_string()).unwrap();
let o = Options { script: script.to_string_lossy().into(), out: String::new(), view: StandardView::Iso, size: (320, 200) };
for policy in [Policy::Auto, Policy::Cpu] {
let (png, drawn) = picture(&o, policy).unwrap();
let (w, h, px) = decode(&png);
assert_eq!((w, h), (320, 200), "{drawn:?}");
let hex = Palette::LIGHT.hex;
let near = |p: &[u8], c: fieldcraft_render::Rgb, d: u8| p[0].abs_diff(c.0) <= d && p[1].abs_diff(c.1) <= d && p[2].abs_diff(c.2) <= d;
// Shaded faces vary in brightness around the hex colour; the edges are near black.
let faces = px.chunks(4).filter(|p| near(p, hex, 60) && p[2] > p[0]).count();
let edges = px.chunks(4).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 90).count();
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
if policy == Policy::Cpu {
assert_eq!(drawn, Drawn::Cpu { why: None });
}
}
std::fs::remove_dir_all(&dir).ok();
}
}
+22 -6
View File
@@ -264,11 +264,21 @@ pub fn orientation(p: &Value) -> Result<Orientation> {
Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0"))
}
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn but the
/// A picture ready to draw: the scene (with its background and clipping radius), the setup's
/// symbols as a layer over it, and the camera.
pub struct Framed {
pub scene: Scene,
pub symbols: Scene,
pub cam: Camera,
pub shown: Shown,
}
/// The session seen from `at` in a `w` × `h` pixel picture: framing everything drawn but the
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
/// `at`'s distance when it has one. The symbols are drawn at their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
/// `at`'s distance when it has one. The symbols are sized for their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera. The CPU rasterizer ([`render_png`]) and
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`].
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Framed {
let mut pic = picture(s, p);
let mut cam = Camera::default();
let aspect = w as f64 / h.max(1) as f64;
@@ -287,8 +297,14 @@ pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette
bounds.union(pic.symbols(unit(&cam), &mut symbols));
bounds.union(pic.datums);
pic.scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
let png = fieldcraft_render::render_layers_png(&pic.scene, &[&symbols], &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, pic.shown))
Framed { scene: pic.scene, symbols, cam, shown: pic.shown }
}
/// A PNG of the session seen from `at`, `w` × `h` pixels ([`frame`]), drawn on the CPU.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let f = frame(s, at, w, h, p);
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, f.shown))
}
#[cfg(test)]
+5 -5
View File
@@ -206,14 +206,14 @@ fn over_background(px: &[u8], w: usize, h: usize, bg: (Rgb, Rgb)) -> Vec<u8> {
let f = |c: Rgb| [c.0, c.1, c.2].map(f64::from);
let (top, bottom) = (f(bg.0), f(bg.1));
let mut out = px.to_vec();
for (i, p) in out.chunks_exact_mut(4).enumerate() {
for (i, [r, g, b, a]) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let t = (i / w.max(1)) as f64 / h.max(1) as f64;
let k = 1.0 - f64::from(p[3]) / 255.0;
for (c, (a, b)) in p.iter_mut().zip(top.iter().zip(&bottom)) {
let back = a + (b - a) * t;
let k = 1.0 - f64::from(*a) / 255.0;
for (c, (top, bottom)) in [r, g, b].into_iter().zip(top.iter().zip(&bottom)) {
let back = top + (bottom - top) * t;
*c = (f64::from(*c) + back * k).round().clamp(0.0, 255.0) as u8;
}
p[3] = 255;
*a = 255;
}
out
}
+1
View File
@@ -32,6 +32,7 @@ crates in strictly lower layers.
| L4 | `fieldcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots` (in the browser, files kept in memory: `memory`). Result files later. | started |
| L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started |
| L7 | `fieldcraft-gpu` | The viewport's GPU pipelines (lit faces, wide lines, MSAA, depth) without a window toolkit, the adapter policy (`FIELDCRAFT_GPU`: hardware GPU, software GPU, CPU rasterizer) and offscreen pictures (`fieldcraft-cli snapshot`). The window draws through the same pipelines. | started |
| L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started |
| testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser (docs/web.md). | started |