forked from 1b-agent/fieldcraft-upstream
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
d84dae074d
commit
89651b707c
9 files changed
+210
-19
No files matched your search
@@ -10,7 +10,8 @@
|
||||
|
||||
use fieldcraft_model::ElementKind;
|
||||
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Rgb, Scene, SceneLine, SceneTri, StandardView, Vec3};
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::results::{displacements, field_values, magnitude, range};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -174,12 +175,31 @@ pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
|
||||
(scene, bounds, shown)
|
||||
}
|
||||
|
||||
/// A PNG of the session seen from `view`, framing everything drawn, `w` × `h` pixels.
|
||||
pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
|
||||
/// A camera orientation from JSON, `{"yaw": …, "pitch": …, "distance"?: …}`: degrees, and the
|
||||
/// distance from the target in model units (the control channel's `ui.camera`, the MCP
|
||||
/// `screenshot` tool's `camera`). Hostile input is refused or clamped
|
||||
/// ([`Orientation::checked`]).
|
||||
pub fn orientation(p: &Value) -> Result<Orientation> {
|
||||
let bad = |m: &str| EngineError::Params(format!("camera: {m}"));
|
||||
let o = p.as_object().ok_or_else(|| bad("an object {yaw, pitch, distance?} in degrees"))?;
|
||||
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "yaw" | "pitch" | "distance")) {
|
||||
return Err(bad(&format!("unknown field `{}` (yaw, pitch, distance)", k.chars().take(40).collect::<String>())));
|
||||
}
|
||||
let number = |k: &str| o.get(k).filter(|v| !v.is_null()).map(|v| v.as_f64().ok_or_else(|| bad(&format!("`{k}` must be a number"))));
|
||||
let yaw = number("yaw").ok_or_else(|| bad("`yaw` (degrees) is required"))??;
|
||||
let pitch = number("pitch").ok_or_else(|| bad("`pitch` (degrees) is required"))??;
|
||||
let distance = number("distance").transpose()?;
|
||||
Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0"))
|
||||
}
|
||||
|
||||
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn, or from
|
||||
/// `at`'s distance when it has one.
|
||||
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
|
||||
let (mut scene, bounds, shown) = scene(s, p);
|
||||
let mut cam = Camera::default();
|
||||
cam.set_view(view);
|
||||
cam.orient(&Orientation { distance: None, ..*at });
|
||||
cam.fit(&bounds);
|
||||
cam.orient(at);
|
||||
scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
|
||||
let png = fieldcraft_render::render_png(&scene, &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
|
||||
Ok((png, shown))
|
||||
@@ -187,25 +207,56 @@ pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palet
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_render::StandardView;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_the_model_and_its_results() {
|
||||
let iso = Orientation::of(StandardView::Iso);
|
||||
let mut s = Session::default();
|
||||
let empty = render_png(&s, StandardView::Iso, 32, 24, &Palette::LIGHT).unwrap();
|
||||
let empty = render_png(&s, &iso, 32, 24, &Palette::LIGHT).unwrap();
|
||||
assert!(empty.0.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
s.run_script(&crate::sample::cantilever()).unwrap();
|
||||
let (model, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap();
|
||||
let (model, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap();
|
||||
assert_eq!(shown, Shown::default());
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let (plot, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap();
|
||||
let (plot, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap();
|
||||
assert_ne!(model, plot);
|
||||
assert!(shown.scale > 0.0 && shown.step.is_some(), "{shown:?}");
|
||||
let (field, min, max) = shown.contour.unwrap();
|
||||
assert_eq!(field, "von_mises");
|
||||
assert!(max.1 > min.1);
|
||||
assert!(render_png(&s, StandardView::Top, 0, 48, &Palette::LIGHT).is_err());
|
||||
assert!(render_png(&s, &Orientation::of(StandardView::Top), 0, 48, &Palette::LIGHT).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_orientation_is_checked() {
|
||||
let o = orientation(&json!({"yaw": -45, "pitch": 30})).unwrap();
|
||||
assert_eq!((o.yaw, o.pitch, o.distance), (-45.0, 30.0, None));
|
||||
let o = orientation(&json!({"yaw": 400, "pitch": -400, "distance": 1e300})).unwrap();
|
||||
assert_eq!((o.yaw, o.pitch, o.distance), (40.0, -90.0, Some(Orientation::DISTANCE.1)));
|
||||
for hostile in [
|
||||
json!(null),
|
||||
json!([1, 2]),
|
||||
json!({}),
|
||||
json!({"yaw": 0}),
|
||||
json!({"pitch": 0}),
|
||||
json!({"yaw": "left", "pitch": 0}),
|
||||
json!({"yaw": 0, "pitch": 0, "distance": 0}),
|
||||
json!({"yaw": 0, "pitch": 0, "distance": -5}),
|
||||
json!({"yaw": 0, "pitch": 0, "distance": "far"}),
|
||||
json!({"yaw": 0, "pitch": 0, "roll": 3}),
|
||||
json!({"yaw": f64::INFINITY, "pitch": 0}),
|
||||
] {
|
||||
assert!(orientation(&hostile).is_err(), "{hostile}");
|
||||
}
|
||||
// A picture from a chosen orientation and distance differs from the framed iso one.
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::cantilever()).unwrap();
|
||||
let iso = render_png(&s, &Orientation::of(StandardView::Iso), 64, 48, &Palette::LIGHT).unwrap().0;
|
||||
let side = render_png(&s, &orientation(&json!({"yaw": -80, "pitch": 10, "distance": 2000})).unwrap(), 64, 48, &Palette::LIGHT).unwrap().0;
|
||||
assert_ne!(iso, side);
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -272,10 +272,13 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"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.",
|
||||
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.",
|
||||
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)"},
|
||||
"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"),
|
||||
@@ -525,8 +528,13 @@ 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())?;
|
||||
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,
|
||||
window: str_arg(a, "source") != Some("model"),
|
||||
width: size("width"),
|
||||
height: size("height"),
|
||||
|
||||
@@ -99,6 +99,40 @@ impl StandardView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a camera looks from, in degrees as people and agents give it: `yaw` turns about Z (0
|
||||
/// looks along +Y, the front view; −45 is the iso view's), `pitch` is the elevation above the XY
|
||||
/// plane and `distance`, when given, the distance from the target (a zoom in orthographic views).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Orientation {
|
||||
pub yaw: f64,
|
||||
pub pitch: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub distance: Option<f64>,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
/// Closest and farthest camera distance.
|
||||
pub const DISTANCE: (f64, f64) = (1e-3, 1e9);
|
||||
|
||||
/// The orientation of a standard view.
|
||||
pub fn of(view: StandardView) -> Self {
|
||||
let (yaw, pitch) = view.angles();
|
||||
Orientation { yaw: yaw.to_degrees(), pitch: pitch.to_degrees(), distance: None }
|
||||
}
|
||||
|
||||
/// A checked orientation: `None` when an angle or the distance is not finite or the distance
|
||||
/// is not above 0. The yaw is wrapped into [−180, 180), the pitch clamped to ±90 and the
|
||||
/// distance to [`Orientation::DISTANCE`].
|
||||
pub fn checked(yaw: f64, pitch: f64, distance: Option<f64>) -> Option<Self> {
|
||||
if !(yaw.is_finite() && pitch.is_finite()) || distance.is_some_and(|d| !(d.is_finite() && d > 0.0)) {
|
||||
return None;
|
||||
}
|
||||
let yaw = (yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
let (near, far) = Self::DISTANCE;
|
||||
Some(Orientation { yaw, pitch: pitch.clamp(-90.0, 90.0), distance: distance.map(|d| d.clamp(near, far)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target`, Z up.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Camera {
|
||||
@@ -241,6 +275,18 @@ impl Camera {
|
||||
self.yaw = y;
|
||||
self.pitch = p.clamp(-lim, lim);
|
||||
}
|
||||
/// Turn to `o` (and move to its distance, when it has one); the pitch stays just short of the
|
||||
/// poles, as an orbit's does.
|
||||
pub fn orient(&mut self, o: &Orientation) {
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
if o.yaw.is_finite() && o.pitch.is_finite() {
|
||||
self.yaw = o.yaw.to_radians();
|
||||
self.pitch = o.pitch.to_radians().clamp(-lim, lim);
|
||||
}
|
||||
if let Some(d) = o.distance.filter(|d| d.is_finite() && *d > 0.0) {
|
||||
self.distance = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1);
|
||||
}
|
||||
}
|
||||
/// Frame a bounding box.
|
||||
pub fn fit(&mut self, b: &Aabb3) {
|
||||
if b.is_empty() {
|
||||
@@ -324,4 +370,31 @@ mod tests {
|
||||
assert_eq!(StandardView::parse(v.label()), Some(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_in_degrees() {
|
||||
// The iso view's angles, given in degrees, turn the camera as the iso view does.
|
||||
let (mut a, mut b) = (Camera::default(), Camera::default());
|
||||
a.set_view(StandardView::Front);
|
||||
a.orient(&Orientation::of(StandardView::Iso));
|
||||
b.set_view(StandardView::Iso);
|
||||
assert!((a.yaw - b.yaw).abs() < 1e-12 && (a.pitch - b.pitch).abs() < 1e-12);
|
||||
let o = Orientation::checked(-45.0, 35.264, Some(300.0)).unwrap();
|
||||
a.orient(&o);
|
||||
assert_eq!(a.distance, 300.0);
|
||||
// Wrapped, clamped, refused.
|
||||
let o = Orientation::checked(540.0, 1e6, Some(1e300)).unwrap();
|
||||
assert_eq!((o.yaw, o.pitch, o.distance), (-180.0, 90.0, Some(Orientation::DISTANCE.1)));
|
||||
a.orient(&o);
|
||||
assert!(a.is_valid() && a.pitch < std::f64::consts::FRAC_PI_2, "{a:?}");
|
||||
for (y, p, d) in
|
||||
[(f64::NAN, 0.0, None), (0.0, f64::INFINITY, None), (0.0, 0.0, Some(0.0)), (0.0, 0.0, Some(-1.0)), (0.0, 0.0, Some(f64::NAN))]
|
||||
{
|
||||
assert_eq!(Orientation::checked(y, p, d), None);
|
||||
}
|
||||
// A hostile orientation built by hand leaves the camera as it was.
|
||||
let before = a;
|
||||
a.orient(&Orientation { yaw: f64::NAN, pitch: 0.0, distance: Some(f64::INFINITY) });
|
||||
assert_eq!(a, before);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ mod display;
|
||||
mod raster;
|
||||
mod vec;
|
||||
|
||||
pub use camera::{Camera, Mat4, StandardView};
|
||||
pub use camera::{Camera, Mat4, Orientation, StandardView};
|
||||
pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri};
|
||||
pub use raster::{
|
||||
Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade,
|
||||
|
||||
@@ -184,6 +184,13 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
ok(json!(app.cam))
|
||||
}
|
||||
"ui.camera" => match fieldcraft_engine::view::orientation(p) {
|
||||
Ok(o) => {
|
||||
app.cam.orient(&o);
|
||||
ok(json!(app.cam))
|
||||
}
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.start" => {
|
||||
let Some(id) = s("command") else { return err("missing `command`") };
|
||||
match app.start_programmatic(id) {
|
||||
@@ -427,6 +434,21 @@ mod tests {
|
||||
assert_eq!(call(&mut app, "ui.view", json!({"view": "top"}))["ok"], true);
|
||||
assert!((app.cam.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
|
||||
assert_eq!(call(&mut app, "ui.view", json!({"view": "sideways"}))["ok"], false);
|
||||
// Any orientation in degrees; hostile numbers are refused or clamped and leave a valid camera.
|
||||
let r = call(&mut app, "ui.camera", json!({"yaw": 90, "pitch": 30, "distance": 250}));
|
||||
assert!((app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-9 && (app.cam.pitch - 30f64.to_radians()).abs() < 1e-9, "{r}");
|
||||
assert_eq!(r["result"]["distance"], 250.0);
|
||||
for hostile in [
|
||||
json!({"yaw": 1}),
|
||||
json!({"yaw": "x", "pitch": 0}),
|
||||
json!({"yaw": 0, "pitch": 0, "distance": 0}),
|
||||
json!({"yaw": 0, "pitch": 0, "spin": 1}),
|
||||
] {
|
||||
assert_eq!(call(&mut app, "ui.camera", hostile.clone())["ok"], false, "{hostile}");
|
||||
}
|
||||
assert_eq!(app.cam.distance, 250.0);
|
||||
assert_eq!(call(&mut app, "ui.camera", json!({"yaw": -1e12, "pitch": 1e300, "distance": 1e300}))["ok"], true);
|
||||
assert!(app.cam.is_valid() && app.cam.pitch < std::f64::consts::FRAC_PI_2);
|
||||
assert_eq!(call(&mut app, "ui.inspect", json!({}))["result"]["model"]["elements"], 64 + 162);
|
||||
assert_eq!(call(&mut app, "ui.screenshot", json!({})), json!({"screenshot": true}));
|
||||
assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false);
|
||||
@@ -495,6 +517,7 @@ mod tests {
|
||||
"engine.script",
|
||||
"ui.set",
|
||||
"ui.view",
|
||||
"ui.camera",
|
||||
"ui.start",
|
||||
"ui.dialog",
|
||||
"ui.move",
|
||||
|
||||
@@ -39,6 +39,7 @@ same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impa
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
|
||||
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera |
|
||||
| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) |
|
||||
| `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) |
|
||||
| `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form |
|
||||
| `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel |
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ software on the computer.
|
||||
| `show_results` | `step?`, `field?`, `bands?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for; the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. |
|
||||
| `probe` | `step?`, `nodes?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, elements (explicit) or points (fluid). |
|
||||
| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. |
|
||||
| `screenshot` | `view?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`); `view` turns the app's camera first. |
|
||||
| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`). `view` turns the camera to a standard view first; `camera` `{yaw, pitch, distance?}` to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (`ui.camera`; headless, the picture is framed unless `distance` is given). Give one of the two; the orientation is kept for the next picture, so a sequence of `camera` angles orbits smoothly. |
|
||||
| `undo`, `redo` | — | `edit.undo`, `edit.redo`. |
|
||||
|
||||
Resources: `fieldcraft://model` (`model.info`) and `fieldcraft://commands` (the command catalog).
|
||||
|
||||
Reference in new issue
Block a user