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

+17 -8
View File
@@ -1,6 +1,6 @@
//! Where tool calls end up: an in-process headless session, or a running app's control channel
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.render`,
//! `ui.capture`).
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.camera`,
//! `ui.render`, `ui.capture`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
@@ -11,7 +11,7 @@ use std::time::Duration;
use fieldcraft_engine::Session;
use fieldcraft_engine::view::{Palette, render_png};
use fieldcraft_io::roots::Roots;
use fieldcraft_render::StandardView;
use fieldcraft_render::{Orientation, StandardView};
use serde_json::{Value, json};
/// What a picture shows.
@@ -19,6 +19,8 @@ use serde_json::{Value, json};
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>,
/// The whole app window (`true`) or the model only.
pub window: bool,
/// Image size in pixels (a window capture: points); `None` keeps the default.
@@ -67,8 +69,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.
pub view: StandardView,
/// The orientation of pictures; each one frames everything drawn, unless the orientation
/// has a distance.
pub view: Orientation,
roots: Roots,
}
@@ -83,7 +86,7 @@ impl Headless {
pub fn new(roots: Roots) -> Self {
let mut session = Session::default();
session.file_access = roots.clone();
Headless { session, view: StandardView::Iso, roots }
Headless { session, view: Orientation::of(StandardView::Iso), roots }
}
}
@@ -98,11 +101,14 @@ impl Backend for Headless {
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
if let Some(v) = &r.view {
self.view = standard_view(v)?;
self.view = Orientation::of(standard_view(v)?);
}
if let Some(o) = r.camera {
self.view = o;
}
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 (png, shown) = render_png(&self.session, self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?;
let (png, shown) = render_png(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?;
let contour = shown
.contour
.map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}}));
@@ -229,6 +235,9 @@ 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))?;
}
let path = temp_png();
let shown = path.to_string_lossy().into_owned();
let mut params = json!({"path": shown});