mcp, cli, ui: camera target, fit and fov in screenshots, snapshot --camera and ui.camera; screenshot section (#467)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 06:28:57 -04:00
1 parent 1f183e04ea
commit 19c0538dd8
9 files changed
+145 -39

No files matched your search

+7 -4
View File
@@ -1,6 +1,6 @@
//! fieldcraft-cli commands
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V] [--size WxH] [--section x|y|z[=V]] [--field F]
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V | --camera JSON] [--size WxH] [--section x|y|z[=V]] [--field F]
//! 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>]]
@@ -25,8 +25,8 @@ 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]
[--section x|y|z[=VALUE]] [--field FIELD]
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V | --camera JSON]
[--size WxH] [--section x|y|z[=VALUE]] [--field FIELD]
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=
@@ -34,7 +34,10 @@ usage:
front, back, left, right, top, bottom; size 1280x800;
--section hides the elements beyond a plane (at the
mesh's middle, or VALUE); --field contours a nodal
field of the solved step (results.contour)
field of the solved step (results.contour);
--camera '{\"yaw\":30,\"pitch\":20,\"target\":...,
\"fit\":true,\"fov\":35}' aims as MCP screenshot's camera
(default target: the model's centre of mass)
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
+35 -6
View File
@@ -4,7 +4,7 @@
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
//! [`fieldcraft_gpu::select`]).
use fieldcraft_engine::view::{self, Palette, Section};
use fieldcraft_engine::view::{self, Aim, Palette, Section, Target};
use fieldcraft_gpu::Renderer;
use fieldcraft_gpu::capture::{self, Capturer};
use fieldcraft_gpu::select::Policy;
@@ -18,6 +18,8 @@ pub struct Options {
pub script: String,
pub out: String,
pub view: StandardView,
/// A camera aimed as the MCP `screenshot`'s (`--camera JSON`; not with `--view`).
pub camera: Option<Aim>,
pub size: (u32, u32),
/// A section through the mesh (`--section x|y|z[=VALUE]`).
pub section: Option<Section>,
@@ -46,7 +48,7 @@ fn size_of(s: &str) -> Option<(u32, u32)> {
/// 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, mut section, mut field) = (None, None, None, None, None, None);
let (mut script, mut out, mut view, mut size, mut section, mut field, mut camera) = (None, None, None, None, None, None, None);
let mut it = args.iter();
while let Some(arg) = it.next() {
if !arg.starts_with("--") {
@@ -80,16 +82,24 @@ pub fn options(args: &[String]) -> Result<Options, String> {
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
.is_some(),
"--field" => field.replace(value).is_some(),
"--camera" => {
let v: serde_json::Value = serde_json::from_str(&value).map_err(|e| format!("--camera: not JSON: {e}"))?;
camera.replace(view::camera(&v).map_err(|e| format!("--camera: {e}"))?).is_some()
}
other => return Err(format!("snapshot: unknown option `{other}`")),
};
if twice {
return Err(format!("{flag} is given twice"));
}
}
if view.is_some() && camera.is_some() {
return Err("give --view or --camera, not both".into());
}
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),
camera,
size: size.unwrap_or(DEFAULT_SIZE),
section,
field,
@@ -103,7 +113,10 @@ pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
}
let (w, h) = o.size;
let f = view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section);
let f = match &o.camera {
Some(aim) => view::frame_at(&session, aim, w as usize, h as usize, &Palette::LIGHT, o.section).map_err(|e| e.to_string())?,
None => view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section),
};
let capturer = Capturer::open(policy);
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
Ok((png, renderer, f.shown))
@@ -119,7 +132,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
r.insert("out".into(), o.out.clone().into());
r.insert("width".into(), o.size.0.into());
r.insert("height".into(), o.size.1.into());
r.insert("view".into(), o.view.label().into());
match &o.camera {
Some(aim) => r.insert("camera".into(), Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() }.json()),
None => r.insert("view".into(), o.view.label().into()),
};
}
println!("{report}");
Ok(())
@@ -138,7 +154,15 @@ mod tests {
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, section: None, field: None }
Options {
script: "s.json".into(),
out: "a.png".into(),
view: StandardView::Iso,
camera: None,
size: DEFAULT_SIZE,
section: None,
field: None
}
);
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
@@ -147,6 +171,8 @@ mod tests {
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);
let o = opts(&["s", "--out", "o", "--camera", r#"{"yaw": 30, "pitch": 10, "target": [1, 2, 3], "fit": true}"#]).unwrap();
assert_eq!(o.camera.and_then(|a| a.target), Some(Target::Point(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0))));
for bad in [
vec![],
vec!["s.json"],
@@ -164,6 +190,9 @@ mod tests {
vec!["s", "--out", "o", "--section", "w"],
vec!["s", "--out", "o", "--section", "x=inf"],
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
vec!["s", "--out", "o", "--camera", "{yaw: 1}"],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0}"#],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0, "pitch": 0}"#, "--view", "x"],
] {
assert!(opts(&bad).is_err(), "{bad:?}");
}
@@ -189,7 +218,7 @@ mod tests {
}
fn opts_for(script: String) -> Options {
Options { script, out: String::new(), view: StandardView::Iso, size: (320, 200), section: None, field: None }
Options { script, out: String::new(), view: StandardView::Iso, camera: None, size: (320, 200), section: None, field: None }
}
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels