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 <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-16 06:28:57 -04:00
1 parent 1f183e04ea
commit 19c0538dd8
9 files changed
+145 -39

No files matched your search

+28 -14
View File
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use fieldcraft_engine::Session;
use fieldcraft_engine::view::{Palette, Shown};
use fieldcraft_engine::view::{Aim, Palette, Section, Shown, Target};
use fieldcraft_gpu::Renderer;
use fieldcraft_io::roots::Roots;
use fieldcraft_render::{Orientation, StandardView};
@@ -56,8 +56,11 @@ pub fn picture_report(r: &Renderer, shown: &Shown) -> Value {
pub struct PictureRequest {
/// A standard view to turn to first (`iso`, `front`…; `home` and `fit` frame the model).
pub view: Option<String>,
/// A camera orientation to turn to first (degrees; `view` and `camera` are not given together).
pub camera: Option<Orientation>,
/// A camera to aim first ([`fieldcraft_engine::view::camera`]; `view` and `camera` are not
/// given together). Without a target it looks at the model's centre of mass.
pub camera: Option<Aim>,
/// A section through the mesh, as `fieldcraft-cli snapshot --section` (headless only).
pub section: Option<Section>,
/// The whole app window (`true`) or the model only.
pub window: bool,
/// Image size in pixels (a window capture: points); `None` keeps the default.
@@ -108,9 +111,9 @@ fn standard_view(v: &str) -> Result<StandardView, String> {
/// An in-process headless session; pictures come from the CPU rasterizer.
pub struct Headless {
pub session: Session,
/// The orientation of pictures; each one frames everything drawn, unless the orientation
/// has a distance.
pub view: Orientation,
/// The camera of pictures: a standard view frames everything drawn (unless it has a
/// distance); a camera given aims at its target ([`fieldcraft_engine::view::frame_at`]).
pub view: Aim,
roots: Roots,
}
@@ -125,7 +128,7 @@ impl Headless {
pub fn new(roots: Roots) -> Self {
let mut session = Session::default();
session.file_access = roots.clone();
Headless { session, view: Orientation::of(StandardView::Iso), roots }
Headless { session, view: Aim::new(Orientation::of(StandardView::Iso)), roots }
}
}
@@ -140,19 +143,25 @@ impl Backend for Headless {
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
if let Some(v) = &r.view {
self.view = Orientation::of(standard_view(v)?);
self.view = Aim::new(Orientation::of(standard_view(v)?));
}
if let Some(o) = r.camera {
self.view = o;
if let Some(a) = &r.camera {
self.view = Aim { target: Some(a.target.clone().unwrap_or(Target::MODEL)), ..a.clone() };
}
let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE);
let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE);
let f = fieldcraft_engine::view::frame(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT, None);
let f = if self.view.target.is_some() {
fieldcraft_engine::view::frame_at(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT, r.section)
.map_err(|e| e.to_string())?
} else {
fieldcraft_engine::view::frame(&self.session, &self.view.at, w as usize, h as usize, &Palette::LIGHT, r.section)
};
let (png, renderer) = draw(&f, w, h)?;
let mut info = picture_report(&renderer, &f.shown);
if let Some(o) = info.as_object_mut() {
o.insert("source".into(), "model".into());
o.insert("view".into(), json!(self.view));
o.insert("view".into(), self.view.json());
o.insert("camera".into(), json!(f.cam));
o.insert("width".into(), w.into());
o.insert("height".into(), h.into());
}
@@ -277,8 +286,13 @@ impl Backend for Remote {
standard_view(v)?;
self.call("ui.view", json!({"view": v}))?;
}
if let Some(o) = r.camera {
self.call("ui.camera", json!(o))?;
if r.section.is_some() {
return Err("screenshot: `section` draws headless only (the app's window has no section view)".into());
}
if let Some(a) = &r.camera {
// Aimed as a headless picture is: at the model's centre of mass unless told otherwise.
let a = Aim { target: Some(a.target.clone().unwrap_or(Target::MODEL)), ..a.clone() };
self.call("ui.camera", a.json())?;
}
let path = temp_png();
let shown = path.to_string_lossy().into_owned();
+13 -1
View File
@@ -219,11 +219,23 @@ fn screenshot_takes_a_camera_orientation() {
let (iso, _) = picture(&mut s, json!({"view": "iso", "width": 64, "height": 48}));
let (turned, info) = picture(&mut s, json!({"camera": {"yaw": 30, "pitch": 1e9, "distance": 1e300}, "width": 64, "height": 48}));
assert_ne!(iso, turned);
assert_eq!(info["view"], json!({"yaw": 30.0, "pitch": 90.0, "distance": 1e9}), "{info}");
assert_eq!(info["view"], json!({"yaw": 30.0, "pitch": 90.0, "distance": 1e9, "target": {"center_of": "mass"}}), "{info}");
// The reply names what drew it: the viewport's pipeline on a GPU, or the CPU rasterizer.
assert!(matches!(info["renderer"]["kind"].as_str(), Some("hardware" | "software" | "cpu")), "{info}");
let (again, _) = picture(&mut s, json!({"width": 64, "height": 48}));
assert_eq!(again, turned);
// Aimed at a point, the camera looks at it; a section cuts the mesh.
let (_, info) =
picture(&mut s, json!({"camera": {"yaw": 0, "pitch": 0, "target": [1, 2, 3], "fit": true, "fov": 30}, "width": 64, "height": 48}));
assert_eq!(info["camera"]["target"], json!({"x": 1.0, "y": 2.0, "z": 3.0}), "{info}");
assert!((info["camera"]["fov"].as_f64().unwrap() - 30f64.to_radians()).abs() < 1e-12, "{info}");
let (whole, _) = picture(&mut s, json!({"width": 64, "height": 48}));
let (cut, _) = picture(&mut s, json!({"section": "y", "width": 64, "height": 48}));
assert_ne!(whole, cut);
for bad in [json!({"section": "w"}), json!({"camera": {"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": ["Nope"]}}})] {
let (text, err) = call(&mut s, "screenshot", bad.clone());
assert!(err, "{bad}: {text}");
}
}
/// `probe {at}` reads a structural result at the nodes nearest to points (#105); hostile points
+41 -7
View File
@@ -307,15 +307,40 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"screenshot",
"Look at the model",
"A PNG of the model and the results display. Headless: a CPU render framing everything drawn. Connected to \
the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with the \
app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first, or `camera` to \
any orientation: {yaw, pitch, distance?} in degrees (yaw 0 looks along +Y, -45 is the iso view's; pitch is \
the elevation, clamped to ±90) and model units; a sequence of them orbits smoothly.",
"A PNG of the model and the results display. Headless: drawn offscreen framing everything drawn. Connected \
to the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with \
the app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first, or `camera` \
aims it: {yaw, pitch} in degrees (yaw 0 looks along +Y, -45 is the iso view's; pitch is the elevation, \
clamped to ±90) at `target` (default: the centre of mass of the model drawn, so a sequence of yaws orbits \
it), from `distance` (model units), or with `fit` the targeted bodies filling the frame; without either, \
from the distance that keeps the whole model in view from every side. A camera given moves the app's too. \
`section` cuts the mesh (headless).",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
"camera": {"type": "object", "description": "Camera orientation {yaw, pitch, distance?}: degrees, and the distance from the target in model units (default: framed headless, kept in the app)"},
"camera": {
"type": "object",
"description": "Camera aim; kept for the next screenshot",
"properties": {
"yaw": {"type": "number", "description": "Degrees about Z; 0 looks along +Y"},
"pitch": {"type": "number", "description": "Degrees of elevation, -90 to 90"},
"distance": {"type": "number", "description": "From the target, in model units (not with fit)"},
"target": {
"description": "What to look at: [x, y, z], or {center_of: mass | bbox, bodies?: [design body names]} (default {center_of: mass}, the model drawn)",
"oneOf": [
{"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
{"type": "object", "properties": {
"center_of": {"type": "string", "enum": ["mass", "bbox"]},
"bodies": {"type": "array", "items": {"type": "string"}},
}},
],
},
"fit": {"type": "boolean", "description": "Choose the distance so the targeted bodies (or the model) fill the frame from this direction"},
"fov": {"type": "number", "description": "Vertical field of view in degrees, 0 (orthographic, the default headless) to 150"},
},
"required": ["yaw", "pitch"],
},
"section": string("Cut the mesh normal to an axis, as fieldcraft-cli snapshot --section: x, y, z (at the middle) or AXIS=VALUE (y=2.5); headless only"),
"source": {"type": "string", "enum": ["window", "model"], "description": "window = the app's window, model = a model render"},
"width": integer("Width in pixels (window: points), 16 to 4096"),
"height": integer("Height in pixels (window: points), 16 to 4096"),
@@ -615,13 +640,22 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
None => None,
};
let size = |k: &str| a.get(k).and_then(Value::as_u64).map(|v| u32::try_from(v).unwrap_or(u32::MAX));
let camera = a.get("camera").filter(|v| !v.is_null()).map(fieldcraft_engine::view::orientation).transpose().map_err(|e| e.to_string())?;
let camera = a.get("camera").filter(|v| !v.is_null()).map(fieldcraft_engine::view::camera).transpose().map_err(|e| e.to_string())?;
let section = match a.get("section").filter(|v| !v.is_null()) {
None => None,
Some(v) => Some(
v.as_str()
.and_then(fieldcraft_engine::view::Section::parse)
.ok_or("screenshot: `section` is x, y, z or AXIS=VALUE (x=12.5): the mesh cut normal to that axis")?,
),
};
if camera.is_some() && a.get("view").is_some_and(|v| !v.is_null()) {
return Err("screenshot: give `view` or `camera`, not both".into());
}
let request = PictureRequest {
view: str_arg(a, "view").map(str::to_string),
camera,
section,
window: str_arg(a, "source") != Some("model"),
width: size("width"),
height: size("height"),