forked from 1b-agent/fieldcraft-upstream
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:
1 parent
1f183e04ea
commit
19c0538dd8
9 files changed
+145
-39
No files matched your search
+28
-14
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user