From fee978c7e4dc57e6fda551b2d26bac274022fe49 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:16:27 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- .github/workflows/ci.yml | 2 + Cargo.lock | 3 + README.md | 1 + apps/fieldcraft-cli/Cargo.toml | 5 +- apps/fieldcraft-cli/src/main.rs | 17 ++- apps/fieldcraft-cli/src/snapshot.rs | 205 ++++++++++++++++++++++++++++ crates/engine/src/view.rs | 28 +++- crates/gpu/src/capture.rs | 10 +- docs/architecture.md | 1 + 9 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 apps/fieldcraft-cli/src/snapshot.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0498a85..84e1bbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index e241975..a660a15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/README.md b/README.md index 5dcb0d4..3981f73 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/apps/fieldcraft-cli/Cargo.toml b/apps/fieldcraft-cli/Cargo.toml index 4af9b5b..edfc115 100644 --- a/apps/fieldcraft-cli/Cargo.toml +++ b/apps/fieldcraft-cli/Cargo.toml @@ -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"] } diff --git a/apps/fieldcraft-cli/src/main.rs b/apps/fieldcraft-cli/src/main.rs index 68bd121..8c299a5 100644 --- a/apps/fieldcraft-cli/src/main.rs +++ b/apps/fieldcraft-cli/src/main.rs @@ -1,5 +1,6 @@ //! fieldcraft-cli commands //! fieldcraft-cli run +//! fieldcraft-cli snapshot --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 | replay | dir | clear []] @@ -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 run a script (a JSON array of {\"command\", \"params\"}) and print the model summary + fieldcraft-cli snapshot --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 { 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(()) diff --git a/apps/fieldcraft-cli/src/snapshot.rs b/apps/fieldcraft-cli/src/snapshot.rs new file mode 100644 index 0000000..666c390 --- /dev/null +++ b/apps/fieldcraft-cli/src/snapshot.rs @@ -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 { + 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::().ok()?, h.trim().parse::().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 { + 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 }, +} + +/// 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, 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(&args.iter().map(|s| s.to_string()).collect::>()) + } + + #[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) { + 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(); + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index d844ccf..ca197fe 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -264,11 +264,21 @@ pub fn orientation(p: &Value) -> Result { 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, 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, 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)] diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 198a6b0..7601821 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -206,14 +206,14 @@ fn over_background(px: &[u8], w: usize, h: usize, bg: (Rgb, Rgb)) -> Vec { 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 } diff --git a/docs/architecture.md b/docs/architecture.md index 3b80fb3..222d749 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 |