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 <[email protected]> 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
@@ -1,6 +1,6 @@
|
||||
//! fieldcraft-cli commands
|
||||
//! fieldcraft-cli run <script.json>
|
||||
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V] [--size WxH] [--section x|y|z[=V]] [--field F]
|
||||
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V | --camera JSON] [--size WxH] [--section x|y|z[=V]] [--field F]
|
||||
//! fieldcraft-cli features
|
||||
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
@@ -25,8 +25,8 @@ usage:
|
||||
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
|
||||
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
|
||||
and print the model summary
|
||||
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V] [--size WxH]
|
||||
[--section x|y|z[=VALUE]] [--field FIELD]
|
||||
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V | --camera JSON]
|
||||
[--size WxH] [--section x|y|z[=VALUE]] [--field FIELD]
|
||||
run a script and draw the model to a PNG with no
|
||||
window: the viewport's GPU pipeline on a hardware or
|
||||
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
|
||||
@@ -34,7 +34,10 @@ usage:
|
||||
front, back, left, right, top, bottom; size 1280x800;
|
||||
--section hides the elements beyond a plane (at the
|
||||
mesh's middle, or VALUE); --field contours a nodal
|
||||
field of the solved step (results.contour)
|
||||
field of the solved step (results.contour);
|
||||
--camera '{\"yaw\":30,\"pitch\":20,\"target\":...,
|
||||
\"fit\":true,\"fov\":35}' aims as MCP screenshot's camera
|
||||
(default target: the model's centre of mass)
|
||||
fieldcraft-cli features print docs/features.md (every capability, ready or
|
||||
coming soon) from the capability catalogue
|
||||
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
|
||||
//! [`fieldcraft_gpu::select`]).
|
||||
|
||||
use fieldcraft_engine::view::{self, Palette, Section};
|
||||
use fieldcraft_engine::view::{self, Aim, Palette, Section, Target};
|
||||
use fieldcraft_gpu::Renderer;
|
||||
use fieldcraft_gpu::capture::{self, Capturer};
|
||||
use fieldcraft_gpu::select::Policy;
|
||||
@@ -18,6 +18,8 @@ pub struct Options {
|
||||
pub script: String,
|
||||
pub out: String,
|
||||
pub view: StandardView,
|
||||
/// A camera aimed as the MCP `screenshot`'s (`--camera JSON`; not with `--view`).
|
||||
pub camera: Option<Aim>,
|
||||
pub size: (u32, u32),
|
||||
/// A section through the mesh (`--section x|y|z[=VALUE]`).
|
||||
pub section: Option<Section>,
|
||||
@@ -46,7 +48,7 @@ fn size_of(s: &str) -> Option<(u32, u32)> {
|
||||
|
||||
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
|
||||
pub fn options(args: &[String]) -> Result<Options, String> {
|
||||
let (mut script, mut out, mut view, mut size, mut section, mut field) = (None, None, None, None, None, None);
|
||||
let (mut script, mut out, mut view, mut size, mut section, mut field, mut camera) = (None, None, None, None, None, None, None);
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if !arg.starts_with("--") {
|
||||
@@ -80,16 +82,24 @@ pub fn options(args: &[String]) -> Result<Options, String> {
|
||||
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
|
||||
.is_some(),
|
||||
"--field" => field.replace(value).is_some(),
|
||||
"--camera" => {
|
||||
let v: serde_json::Value = serde_json::from_str(&value).map_err(|e| format!("--camera: not JSON: {e}"))?;
|
||||
camera.replace(view::camera(&v).map_err(|e| format!("--camera: {e}"))?).is_some()
|
||||
}
|
||||
other => return Err(format!("snapshot: unknown option `{other}`")),
|
||||
};
|
||||
if twice {
|
||||
return Err(format!("{flag} is given twice"));
|
||||
}
|
||||
}
|
||||
if view.is_some() && camera.is_some() {
|
||||
return Err("give --view or --camera, not both".into());
|
||||
}
|
||||
Ok(Options {
|
||||
script: script.ok_or("snapshot needs a script")?,
|
||||
out: out.ok_or("snapshot needs --out FILE.png")?,
|
||||
view: view.unwrap_or(StandardView::Iso),
|
||||
camera,
|
||||
size: size.unwrap_or(DEFAULT_SIZE),
|
||||
section,
|
||||
field,
|
||||
@@ -103,7 +113,10 @@ pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::
|
||||
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
|
||||
}
|
||||
let (w, h) = o.size;
|
||||
let f = view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section);
|
||||
let f = match &o.camera {
|
||||
Some(aim) => view::frame_at(&session, aim, w as usize, h as usize, &Palette::LIGHT, o.section).map_err(|e| e.to_string())?,
|
||||
None => view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section),
|
||||
};
|
||||
let capturer = Capturer::open(policy);
|
||||
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
|
||||
Ok((png, renderer, f.shown))
|
||||
@@ -119,7 +132,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
r.insert("out".into(), o.out.clone().into());
|
||||
r.insert("width".into(), o.size.0.into());
|
||||
r.insert("height".into(), o.size.1.into());
|
||||
r.insert("view".into(), o.view.label().into());
|
||||
match &o.camera {
|
||||
Some(aim) => r.insert("camera".into(), Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() }.json()),
|
||||
None => r.insert("view".into(), o.view.label().into()),
|
||||
};
|
||||
}
|
||||
println!("{report}");
|
||||
Ok(())
|
||||
@@ -138,7 +154,15 @@ mod tests {
|
||||
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
|
||||
assert_eq!(
|
||||
o,
|
||||
Options { script: "s.json".into(), out: "a.png".into(), view: StandardView::Iso, size: DEFAULT_SIZE, section: None, field: None }
|
||||
Options {
|
||||
script: "s.json".into(),
|
||||
out: "a.png".into(),
|
||||
view: StandardView::Iso,
|
||||
camera: None,
|
||||
size: DEFAULT_SIZE,
|
||||
section: None,
|
||||
field: None
|
||||
}
|
||||
);
|
||||
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
|
||||
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
|
||||
@@ -147,6 +171,8 @@ mod tests {
|
||||
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
|
||||
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
|
||||
assert_eq!(opts(&["s", "--out", "o", "--view", "front"]).unwrap().view, StandardView::Front);
|
||||
let o = opts(&["s", "--out", "o", "--camera", r#"{"yaw": 30, "pitch": 10, "target": [1, 2, 3], "fit": true}"#]).unwrap();
|
||||
assert_eq!(o.camera.and_then(|a| a.target), Some(Target::Point(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0))));
|
||||
for bad in [
|
||||
vec![],
|
||||
vec!["s.json"],
|
||||
@@ -164,6 +190,9 @@ mod tests {
|
||||
vec!["s", "--out", "o", "--section", "w"],
|
||||
vec!["s", "--out", "o", "--section", "x=inf"],
|
||||
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
|
||||
vec!["s", "--out", "o", "--camera", "{yaw: 1}"],
|
||||
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0}"#],
|
||||
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0, "pitch": 0}"#, "--view", "x"],
|
||||
] {
|
||||
assert!(opts(&bad).is_err(), "{bad:?}");
|
||||
}
|
||||
@@ -189,7 +218,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn opts_for(script: String) -> Options {
|
||||
Options { script, out: String::new(), view: StandardView::Iso, size: (320, 200), section: None, field: None }
|
||||
Options { script, out: String::new(), view: StandardView::Iso, camera: None, size: (320, 200), section: None, field: None }
|
||||
}
|
||||
|
||||
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
|
||||
|
||||
+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"),
|
||||
|
||||
@@ -327,11 +327,8 @@ 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))
|
||||
}
|
||||
"ui.camera" => match fieldcraft_engine::view::camera(p).map_err(|e| e.to_string()).and_then(|a| app.aim_camera(&a)) {
|
||||
Ok(()) => ok(json!(app.cam)),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.start" => {
|
||||
@@ -748,6 +745,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(app.cam.distance, 250.0);
|
||||
assert_eq!(call(&mut app, "ui.camera", json!({"yaw": -1e12, "pitch": 1e300, "distance": 1e300}))["ok"], true);
|
||||
// Aimed at a point it looks at it; at the model's centre of mass, framed (#467).
|
||||
let r = call(&mut app, "ui.camera", json!({"yaw": 30, "pitch": 20, "target": [1, 2, 3], "distance": 40}));
|
||||
assert_eq!((app.cam.target, app.cam.distance), (fieldcraft_render::Vec3::new(1.0, 2.0, 3.0), 40.0), "{r}");
|
||||
let r = call(&mut app, "ui.camera", json!({"yaw": 30, "pitch": 20, "target": {"center_of": "mass"}, "fit": true}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(app.cam.target.dist(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0)) > 1.0 && app.cam.distance < 1e8, "{r}");
|
||||
assert_eq!(call(&mut app, "ui.camera", json!({"yaw": 0, "pitch": 0, "target": {"bodies": ["Nope"]}}))["ok"], false);
|
||||
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}));
|
||||
|
||||
@@ -1894,6 +1894,16 @@ impl ModelApp {
|
||||
self.fit_view();
|
||||
}
|
||||
|
||||
/// Aim the camera as `aim` says ([`fieldcraft_engine::view::camera`], the control channel's
|
||||
/// `ui.camera`): turned, and with a target moved to look at it from the view's free part, as
|
||||
/// a standard view moves it. A framing still waiting is dropped.
|
||||
pub fn aim_camera(&mut self, aim: &fieldcraft_engine::view::Aim) -> Result<(), String> {
|
||||
let (aspect, _) = self.free_part();
|
||||
self.cam = fieldcraft_engine::view::aim(&self.session, aim, self.cam, aspect).map_err(|e| e.to_string())?;
|
||||
self.reframe = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Do the framing asked for ([`framing::Reframe`]) once there is something to frame; the
|
||||
/// viewport calls it when it knows its size and its overlays, before drawing.
|
||||
pub(crate) fn apply_reframe(&mut self) {
|
||||
|
||||
@@ -48,7 +48,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
| `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`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `job` (that job as `job.status` gives it), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `updates` (docs/updates.md: `automatic`, `checked_at`, `skip`, `available` (a newer version the status bar offers), `busy`, `staged`, `unsupported`; null when the run does not check), `selected` (the browser's selected definition `{kind, name}`, or seed `{kind: "seed", edge}`), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/<stage>`), `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`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, contact and tie surfaces, rigid walls), `symbol_size` (the setup symbols' size on screen in points, 6–48, at any zoom), `result_symbols` (the shown step's setup symbols over a results plot; off by default), `result_extremes` (markers at a contour's largest and smallest value), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `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` | every view frames the model as drawn (the deformed shape included; the setup's symbols keep their size on screen and are not framed) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. 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.camera` | `yaw`, `pitch` (degrees), `distance?` (model units), `target?`, `fit?`, `fov?` (degrees) | turns the camera to any orientation about its target (with `target`, `[x, y, z]` or `{"center_of": "mass" \| "bbox", "bodies"?}` as MCP `screenshot`'s camera (docs/mcp.md), it moves to look at that target from `distance`, from the distance that fits the targeted bodies with `fit`, or from the one that keeps the whole model in view from every side; `fov` sets the perspective, 0 orthographic): `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), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `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`, `contact`, `tie`, `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
@@ -122,7 +122,7 @@ software on the computer.
|
||||
| `show_results` | `step?`, `field?`, `bands?`, `range?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for (`deformed: false` draws the undeformed mesh; `scale` is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or `"auto"`; `range: {min?, max?}` clamps the legend, values above `max` drawn in the over colour); 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?`, `at?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, at the nodes nearest to points `at` (structural steps), elements (explicit) or points (fluid). |
|
||||
| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. |
|
||||
| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: an offscreen render with the viewport's GPU pipeline (`FIELDCRAFT_GPU=auto|hardware|software|cpu`; the reply's `renderer` names the tier and adapter, or why the CPU drew it) framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) 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. |
|
||||
| `screenshot` | `view?`, `camera?`, `section?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: an offscreen render with the viewport's GPU pipeline (`FIELDCRAFT_GPU=auto|hardware|software|cpu`; the reply's `renderer` names the tier and adapter, or why the CPU drew it) framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) 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 (framing everything drawn); `camera` aims it: `{yaw, pitch, distance?, target?, fit?, fov?}`, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units. `target` is `[x, y, z]` or `{"center_of": "mass" \| "bbox", "bodies"?: [names]}` (design bodies drawn; an unknown name is an error), by default the centre of mass of the model drawn (element faces, else the bodies), so a sequence of yaws orbits the object and keeps it at the picture's centre. The distance is `distance`; with `fit: true` the one at which the targeted bodies (or the whole model) fill the frame from this direction; without either, the one at which the whole model stays in view from every direction, so a sweep keeps its size. `fov` is the vertical field of view in degrees (0, the default, orthographic; up to 150). Give `view` or `camera`; the camera is kept for the next picture, and the reply's `camera` is the one drawn with (target, distance, angles in radians). Bridged, `camera` moves the app's camera the same way (`ui.camera`; the window is otherwise untouched). `section` cuts the mesh as `fieldcraft-cli snapshot --section` does: `x`, `y`, `z` (at the middle) or `AXIS=VALUE`, hiding the elements on the camera's side (headless only). `fieldcraft-cli snapshot --camera JSON` takes the same camera. |
|
||||
| `select` | `filter?`, `box_mode?`, `mode?`, `clear?`, `geometry?`, `nodes?`, `elements?`, `faces?`, `at?`, `box?` | Bridged only: changes the app's viewport selection (`ui.select`; control-protocol.md, Selection) and returns it. Headless it is an error: selection belongs to the window. |
|
||||
| `selection` | — | Bridged only: the viewport selection (`ui.selection`), with `params`, the selection as a set's members for `model.set.create`. |
|
||||
| `undo`, `redo` | — | `edit.undo`, `edit.redo`. |
|
||||
|
||||
Reference in new issue
Block a user