Camera orientation in degrees: ui.camera and the MCP screenshot's camera

`ui.view` and the MCP `screenshot` tool only turned to the named views, so
a picture sequence could only cut between them. `Orientation` (render) is a
camera orientation in degrees, {yaw, pitch, distance?}, checked as hostile
input: angles finite, distance finite and above 0, yaw wrapped, pitch
clamped to ±90, distance to 0.001–1e9. `modelcraft_engine::view::orientation`
parses it from JSON for both callers:

- control channel `ui.camera {yaw, pitch, distance?}` turns the app's camera;
- MCP `screenshot {camera}` turns it first (bridged through `ui.camera`,
  headless in the CPU render, framed unless a distance is given). `view` and
  `camera` are not given together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 00:15:50 -04:00
1 parent d84dae074d
commit 89651b707c
9 files changed
+210 -19

No files matched your search

+26
View File
@@ -173,6 +173,12 @@ fn hostile_tool_arguments_are_tool_errors() {
("probe", json!({"nodes": [1, 2, 3]})),
("reactions", json!({"sets": ["nope"], "about": [1e308, 0, 0]})),
("screenshot", json!({"view": "iso", "path": "/no/such/dir/x.png"})),
("screenshot", json!({"camera": {"yaw": "left", "pitch": 0}})),
("screenshot", json!({"camera": {"yaw": 0}})),
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "distance": -1}})),
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "distance": 0}})),
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "zoom": 2}})),
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0}, "view": "top"})),
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
("undo", json!({})),
@@ -192,6 +198,26 @@ fn hostile_tool_arguments_are_tool_errors() {
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
}
/// `screenshot {camera}` turns a headless picture to any orientation: hostile angles are clamped,
/// and the orientation is kept for the next picture.
#[test]
fn screenshot_takes_a_camera_orientation() {
let mut s = server();
ok(&mut s, "execute", json!({"command": "file.sample", "params": {"name": "cantilever"}}));
let picture = |s: &mut Server, args: Value| {
let r = send(s, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": args}}));
let content = r["result"]["content"].as_array().cloned().unwrap_or_default();
let info: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap();
(content[0]["data"].as_str().unwrap().to_string(), info)
};
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}");
let (again, _) = picture(&mut s, json!({"width": 64, "height": 48}));
assert_eq!(again, turned);
}
#[test]
fn a_batch_stops_at_the_first_error() {
let mut s = server();